diff --git a/RELEASE.md b/RELEASE.md index 47c6f305..eb57cdc0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,9 +1,29 @@ -# v1.6.73 — `mysql:createdb` works on a fresh install again +# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, App Review access, and Sign in with Fleetbase -## Fixed +## Added -- **`mysql:createdb` failed on a fresh install with "Unknown database".** At boot, `Setting::doesntHaveConnection()` opens the `mysql` connection with the configured schema selected and Laravel caches it. Clearing the schema name in config afterwards had no effect on that cached connection, so `CREATE DATABASE` ran on a connection pointing at the database it was meant to create. The command now purges the `mysql` connection before each `CREATE DATABASE`, and again after restoring the schema name. `Setting::hasConnection()` also purges the connection when its check fails, so a broken connection isn't left cached for other code. +- **Webhook endpoints can be managed with an API key** (#319). `/v1/webhook-endpoints` supports create, list (optionally filtered by `url`), show, update, `POST {id}/rotate-secret` and delete. Endpoints are scoped to the key's company and environment (`live`/`test`). The URL must pass `PublicWebhookUrl`, and events are checked against `config('api.events')` when it is set. Integrations such as WooCommerce and Shopify connectors can now register their own endpoints instead of merchants copying URLs into the console. +- **Per-endpoint signing secrets** (#319). Each endpoint has its own secret (`whsec_…`), stored encrypted, hidden from serialisation and kept out of the activity log. Endpoints that already exist get one the first time it is needed. Deliveries now also carry `X-Fleetbase-Signature: t=,v1=.", secret)>`; `EndpointSignature::verify()` documents the receiver side. The legacy `Signature` header is unchanged. The console can reveal (`GET int/v1/webhook-endpoints/{id}/secret`, view permission) and rotate (`POST int/v1/webhook-endpoints/{id}/rotate-secret`, create permission) the secret. +- **App Review access** (#323). Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (`Fleetbase\Support\AppReview::register()`, e.g. a Storefront store or an organization's Navigator app) with a review number from the 555-0100–0199 range reserved for fiction. Owners turn access on in the Console (`GET|PUT int/v1/review-access/{type}/{id}`) and give the app store that number and a six-digit code, stored hashed, shown once, and accepted only for that subject. Works on Fleetbase Cloud and self-hosted. `AppReview::configuredAccountMatches()` is the shared env fallback for self-hosted review accounts. +- **Sign in with Fleetbase**, an OAuth 2.0 / OpenID Connect provider (#324). Registered apps can offer "Continue with Fleetbase" and, with the user's consent, act on one organization they administer. Built on league/oauth2-server. + - Authorization code flow with PKCE (`S256`) required for every client, rotating refresh tokens, RFC 7009 revocation, ID tokens, `/oauth/userinfo`, `/oauth/jwks` and `/.well-known/openid-configuration`. + - The consent screen runs in the console, so the console's sign-in and two-factor rules apply. Endpoints: `int/v1/oauth-server/authorization` (GET, POST, DELETE). + - Each consent is an `oauth_grants` row bound to the organization the user picked. Every refresh and every API call checks that the user is still active and still administers that organization. + - Scopes: `openid`, `profile`, `email` and `organization:read` (`GET /oauth/v1/organization`). Extensions register their own scopes with `Fleetbase\Auth\OAuthServer\OAuthScopes::register()`, optionally covering App Review types served by `/oauth/v1/review-access/{type}/{id}`. + - Replayed codes revoke their tokens; a replayed refresh token revokes the whole grant. + - System admins register apps, rotate secrets, turn apps off and revoke grants (`int/v1/oauth-server/apps`). Users list and remove their connected apps (`int/v1/oauth-server/connected-apps`) and get an email when they authorize a new one. + - Artisan: `oauth-server:keys`, `oauth-server:client create|rotate|list`, `oauth-server:prune` (scheduled daily). +- **Dynamic client registration for software installed on many sites** (RFC 7591). A plugin on a merchant's own domain cannot share one registered redirect URI, so each install registers its own public PKCE client at `POST /oauth/register` (throttled, advertised as `registration_endpoint` in discovery). Only software listed under `oauth-server.registration.software` can register, with the scopes listed there; today that is `fleetbase-woocommerce` (`organization:read`, `api-key:create`). Each client has exactly one redirect URI (https, or http on loopback as a development client), is never official, and is named after its host, e.g. "Fleetbase for WooCommerce (shop.example.com)", which is what the consent screen and Connected apps show. Registering again returns the same client; clients that never get a grant are pruned after a day. `OAUTH_SERVER_REGISTRATION_ENABLED=false` turns it off. +- **`api-key:create` scope** and `POST /oauth/v1/api-key`. Creates a live API key named after the app in the token's organization, or returns the one that app created before (tracked by the new `api_credentials.oauth_client_id`, never by name). The key is listed under Developers → API Keys. When the app's last grant in that organization is revoked (Connected apps, an admin revoking it, or the app being turned off), the key expires. This lets an integration such as Fleetbase for WooCommerce connect without anyone pasting a key. An app that serves many installations from one client (the Shopify app, one per shop) passes `installation` and gets a key per installation, named "{app} ({installation})" and tracked by `api_credentials.oauth_installation`. `DELETE /oauth/v1/api-key` (same scope, same optional `installation`) expires one installation's key without touching the grant or other installations. + +## Security + +- **Numbers reserved for fiction are never texted** (#323). `SmsService::send()` answers as sent without calling a provider, and SMS verification codes for those numbers are saved but not sent. +- **Uploaded files are stored under an extension that matches their contents** (#320). The stored name used to keep the client's extension, so a file that passed content-type validation could be stored as `.php`. The client's extension is now kept only when it matches the detected type (or is a loosely detected data format such as `csv`, `json` or `xlsx`); otherwise the extension comes from the detected type. Script extensions (`php`, `phtml`, `phar`, `shtml`, `htaccess`, `cgi`, …) are never used and become `bin`. `original_filename` is unchanged. +- **OAuth provider hardening** (#324). OAuth client secrets are stored only as bcrypt hashes and shown once. Redirect URIs match exactly, and errors about an unknown client or unregistered redirect URI are never redirected. OAuth rows always use the core database connection, even when the console is in sandbox mode. ## Upgrade Steps -- None. Deployments that worked around this with an override of `CreateDatabase` in `api/app/Console/Commands` can delete the override and its binding in `AppServiceProvider`. +- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column, the `review_accesses` table, the `oauth_*` provider tables (`oauth_clients`, `oauth_grants`, `oauth_auth_codes`, `oauth_access_tokens`, `oauth_refresh_tokens`), `oauth_clients.software_id` and `registration_key`, and `api_credentials.oauth_client_id` and `oauth_installation`. +- To use Sign in with Fleetbase, give every API instance the same signing key pair: `php artisan oauth-server:keys` (key files in `storage/`), or `php artisan oauth-server:keys --show` and set `OAUTH_SERVER_PRIVATE_KEY` / `OAUTH_SERVER_PUBLIC_KEY`. Set `OAUTH_SERVER_ISSUER` if the public API URL differs from `APP_URL`. New dependencies: `league/oauth2-server`, `nyholm/psr7`, `symfony/psr-http-message-bridge`. +- The consent screen, Admin → Auth Config → OAuth Apps and Account → Auth → Connected apps ship in the console (fleetbase/fleetbase#734). Storefront's `storefront:*` scopes ship in fleetbase/storefront#163. diff --git a/composer.json b/composer.json index 28437100..772a1b70 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.73", + "version": "1.6.74", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", @@ -45,9 +45,11 @@ "laravel/socialite": "^5.31", "lcobucci/clock": "3.3.1", "lcobucci/jwt": "^5.4", + "league/oauth2-server": "^8.5.5", "maatwebsite/excel": "^3.1", "milon/barcode": "^10.0", "mossadal/math-parser": "^1.3", + "nyholm/psr7": "^1.5", "phpoffice/phpspreadsheet": "^1.28", "phrity/websocket": "^1.7", "pragmarx/google2fa": "^8.0", @@ -61,6 +63,7 @@ "spatie/laravel-schedule-monitor": "^3.7", "spatie/laravel-sluggable": "^3.5", "sqids/sqids": "^0.4.1", + "symfony/psr-http-message-bridge": "^2.1|^6.0|^7.0", "xantios/mimey": "^2.2.0" }, "require-dev": { diff --git a/config/fleetbase.php b/config/fleetbase.php index d93a9a97..412cd5ea 100644 --- a/config/fleetbase.php +++ b/config/fleetbase.php @@ -3,37 +3,55 @@ /** * ------------------------------------------- * Fleetbase Core API Configuration - * ------------------------------------------- + * -------------------------------------------. */ return [ 'api' => [ 'version' => 'v1', 'routing' => [ - 'prefix' => env('API_PREFIX'), - 'internal_prefix' => env('INTERNAL_API_PREFIX', 'int') - ] + 'prefix' => env('API_PREFIX'), + 'internal_prefix' => env('INTERNAL_API_PREFIX', 'int'), + ], ], 'console' => [ - 'path' => env('CONSOLE_PATH', '/fleetbase/console'), - 'host' => env('CONSOLE_HOST', 'fleetbase.io'), + 'path' => env('CONSOLE_PATH', '/fleetbase/console'), + 'host' => env('CONSOLE_HOST', 'fleetbase.io'), 'subdomain' => env('CONSOLE_SUBDOMAIN'), - 'secure' => env('CONSOLE_SECURE', !app()->environment(['development', 'local'])) + 'secure' => env('CONSOLE_SECURE', !app()->environment(['development', 'local'])), ], 'services' => [ 'ipinfo' => [ - 'api_key' => env('IPINFO_API_KEY') - ] + 'api_key' => env('IPINFO_API_KEY'), + ], ], 'connection' => [ - 'db' => env('DB_CONNECTION', 'mysql'), - 'sandbox' => env('SANDBOX_DB_CONNECTION', 'sandbox') + 'db' => env('DB_CONNECTION', 'mysql'), + 'sandbox' => env('SANDBOX_DB_CONNECTION', 'sandbox'), ], 'branding' => [ 'logo_url' => 'https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/fleetbase-logo.png', - 'icon_url' => 'https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/fleetbase-icon.png' + 'icon_url' => 'https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/fleetbase-icon.png', ], 'version' => env('FLEETBASE_VERSION', '0.7.1'), + + /* + |-------------------------------------------------------------------------- + | Fleetbase Hosted Routing + |-------------------------------------------------------------------------- + | + | A free routing key at install time (Support\HostedRouting), with the + | first admin's consent at onboarding or `php artisan routing:register`. + | ROUTING_AUTO_REGISTER=false hides the onboarding choice and makes the + | command refuse unless --force. + */ + 'hosted_routing' => [ + 'auto_register' => env('ROUTING_AUTO_REGISTER', true), + 'registration_url' => env('ROUTING_REGISTRATION_URL', 'https://api.fleetbase.io/__/licenses/register-instance'), + 'api_host' => env('ROUTING_API_HOST', 'https://valhalla.routing.fleetbase.io'), + 'domain' => env('ROUTING_HOSTED_DOMAIN', 'routing.fleetbase.io'), + 'timeout' => env('ROUTING_REGISTRATION_TIMEOUT', 10), + ], 'instance_id' => env('FLEETBASE_INSTANCE_ID') ?? (file_exists(base_path('.fleetbase-id')) ? trim(file_get_contents(base_path('.fleetbase-id'))) : null), /* @@ -52,8 +70,8 @@ 'sms_auth_bypass_code' => env('SMS_AUTH_BYPASS_CODE'), 'user_cache' => [ - 'enabled' => env('USER_CACHE_ENABLED', true), - 'server_ttl' => (int) env('USER_CACHE_SERVER_TTL', 900), // 15 minutes + 'enabled' => env('USER_CACHE_ENABLED', true), + 'server_ttl' => (int) env('USER_CACHE_SERVER_TTL', 900), // 15 minutes 'browser_ttl' => (int) env('USER_CACHE_BROWSER_TTL', 300), // 5 minutes ], diff --git a/config/oauth-server.php b/config/oauth-server.php new file mode 100644 index 00000000..54e5a4b1 --- /dev/null +++ b/config/oauth-server.php @@ -0,0 +1,77 @@ + env('OAUTH_SERVER_ENABLED', true), + + /* + * The issuer identifier (`iss`), and the base of every endpoint in the discovery document. + * Defaults to APP_URL, e.g. https://api.fleetbase.io. + */ + 'issuer' => env('OAUTH_SERVER_ISSUER'), + + /* + * PEM key pair for signing access tokens and ID tokens. When unset, the keys are read from + * storage/oauth-private.key and storage/oauth-public.key (`php artisan oauth-server:keys`). + */ + 'private_key' => env('OAUTH_SERVER_PRIVATE_KEY'), + 'public_key' => env('OAUTH_SERVER_PUBLIC_KEY'), + + /* + * Lifetimes, as ISO 8601 durations. + */ + 'ttl' => [ + 'auth_code' => env('OAUTH_SERVER_AUTH_CODE_TTL', 'PT10M'), + 'access_token' => env('OAUTH_SERVER_ACCESS_TOKEN_TTL', 'PT1H'), + 'refresh_token' => env('OAUTH_SERVER_REFRESH_TOKEN_TTL', 'P30D'), + 'id_token' => env('OAUTH_SERVER_ID_TOKEN_TTL', 'PT1H'), + ], + + /* + * `prompt=login` re-authenticates a console session older than this many seconds. + */ + 'reauthenticate_after' => (int) env('OAUTH_SERVER_REAUTHENTICATE_AFTER', 300), + + /* + * The console page that shows the consent screen. + */ + 'console_authorize_path' => '/oauth/authorize', + + /* + * Dynamic client registration (RFC 7591), for software installed on many sites. + * + * A plugin such as Fleetbase for WooCommerce runs on every merchant's own domain, so it cannot + * share one registered redirect URI. Each install registers its own public PKCE client at + * POST /oauth/register instead, naming the software it is and its one redirect URI. + * + * Only the software listed here can register, and only with the scopes listed for it. The + * client is named "{name} ({redirect host})" and is never marked official, so the consent + * screen always shows which site is asking. Registering again with the same software and + * redirect URI returns the same client. Clients that never get a grant are pruned after + * `unused_client_hours`. + */ + 'registration' => [ + 'enabled' => env('OAUTH_SERVER_REGISTRATION_ENABLED', true), + 'unused_client_hours' => (int) env('OAUTH_SERVER_REGISTRATION_UNUSED_HOURS', 24), + 'software' => [ + 'fleetbase-woocommerce' => [ + 'name' => 'Fleetbase for WooCommerce', + 'description' => 'Live delivery quotes, order sync and tracking for a WooCommerce store.', + 'website_url' => 'https://github.com/fleetbase/woocommerce', + 'logo_url' => env('OAUTH_SERVER_WOOCOMMERCE_LOGO_URL'), + 'scopes' => ['organization:read', 'api-key:create'], + ], + ], + ], +]; diff --git a/migrations/2026_10_22_000000_add_secret_to_webhook_endpoints_table.php b/migrations/2026_10_22_000000_add_secret_to_webhook_endpoints_table.php new file mode 100644 index 00000000..8d58861b --- /dev/null +++ b/migrations/2026_10_22_000000_add_secret_to_webhook_endpoints_table.php @@ -0,0 +1,31 @@ +text('secret')->nullable()->after('version'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_endpoints', function (Blueprint $table) { + $table->dropColumn('secret'); + }); + } +}; diff --git a/migrations/2026_10_24_000000_create_review_accesses_table.php b/migrations/2026_10_24_000000_create_review_accesses_table.php new file mode 100644 index 00000000..dd834906 --- /dev/null +++ b/migrations/2026_10_24_000000_create_review_accesses_table.php @@ -0,0 +1,48 @@ +bigIncrements('id'); + $table->uuid('uuid')->unique(); + $table->uuid('company_uuid')->nullable()->index(); + $table->string('subject_type', 64); + $table->uuid('subject_uuid'); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->uuid('updated_by_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['subject_type', 'subject_uuid']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('review_accesses'); + } +}; diff --git a/migrations/2026_10_25_000001_create_oauth_server_tables.php b/migrations/2026_10_25_000001_create_oauth_server_tables.php new file mode 100644 index 00000000..e5476d1b --- /dev/null +++ b/migrations/2026_10_25_000001_create_oauth_server_tables.php @@ -0,0 +1,115 @@ +char('id', 36)->primary(); + $table->string('name'); + $table->text('description')->nullable(); + // bcrypt hash; null for a public client (a native or single-page app with no secret). + $table->string('secret', 100)->nullable(); + $table->json('redirect_uris'); + $table->json('allowed_scopes'); + $table->string('logo_url')->nullable(); + $table->string('website_url')->nullable(); + $table->string('privacy_url')->nullable(); + $table->string('terms_url')->nullable(); + // Built by Fleetbase (AppHelm). Shown as "Made by Fleetbase" on the consent screen. + $table->boolean('official')->default(false); + // Development clients may use http://127.0.0.1 or http://[::1] on any port (RFC 8252). + $table->boolean('development')->default(false); + $table->char('created_by_uuid', 36)->nullable(); + $table->timestamp('secret_rotated_at')->nullable(); + $table->timestamp('disabled_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('oauth_grants', function (Blueprint $table) { + $table->increments('id'); + $table->char('uuid', 36)->unique(); + $table->foreignUuid('user_uuid')->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE'); + $table->char('client_id', 36); + // Null for a sign-in-only grant (openid, profile, email). + $table->char('company_uuid', 36)->nullable(); + $table->json('scopes'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->foreign('client_id')->references('id')->on('oauth_clients')->onUpdate('CASCADE')->onDelete('CASCADE'); + $table->index(['user_uuid', 'client_id', 'company_uuid']); + $table->index('company_uuid'); + }); + + Schema::create('oauth_auth_codes', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->char('grant_uuid', 36)->index(); + $table->char('client_id', 36); + $table->char('user_uuid', 36); + $table->char('company_uuid', 36)->nullable(); + $table->json('scopes'); + $table->string('nonce', 255)->nullable(); + // When the user last authenticated to the console; becomes the ID token's auth_time. + $table->timestamp('auth_time')->nullable(); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('oauth_access_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->char('grant_uuid', 36)->index(); + $table->char('client_id', 36); + $table->char('user_uuid', 36)->index(); + $table->char('company_uuid', 36)->nullable(); + // The code this token chain started from, so a replayed code can revoke it (RFC 6749 §4.1.2). + $table->string('auth_code_id', 100)->nullable()->index(); + $table->json('scopes'); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamps(); + }); + + Schema::create('oauth_refresh_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->string('access_token_id', 100)->index(); + $table->char('grant_uuid', 36)->index(); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamp('created_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + Schema::dropIfExists('oauth_access_tokens'); + Schema::dropIfExists('oauth_auth_codes'); + Schema::dropIfExists('oauth_grants'); + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/migrations/2026_10_27_000001_add_oauth_registration_columns.php b/migrations/2026_10_27_000001_add_oauth_registration_columns.php new file mode 100644 index 00000000..3dbbdcaf --- /dev/null +++ b/migrations/2026_10_27_000001_add_oauth_registration_columns.php @@ -0,0 +1,50 @@ +string('software_id', 100)->nullable()->after('development')->index(); + $table->char('registration_key', 64)->nullable()->after('software_id')->unique(); + }); + + Schema::table('api_credentials', function (Blueprint $table) { + $table->char('oauth_client_id', 36)->nullable()->after('api')->index(); + $table->string('oauth_installation', 191)->nullable()->after('oauth_client_id')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('api_credentials', function (Blueprint $table) { + $table->dropIndex(['oauth_installation']); + $table->dropIndex(['oauth_client_id']); + $table->dropColumn(['oauth_installation', 'oauth_client_id']); + }); + + Schema::table('oauth_clients', function (Blueprint $table) { + $table->dropUnique(['registration_key']); + $table->dropIndex(['software_id']); + $table->dropColumn(['software_id', 'registration_key']); + }); + } +}; diff --git a/src/Auth/OAuthServer/Entities/AccessTokenEntity.php b/src/Auth/OAuthServer/Entities/AccessTokenEntity.php new file mode 100644 index 00000000..efb5da1f --- /dev/null +++ b/src/Auth/OAuthServer/Entities/AccessTokenEntity.php @@ -0,0 +1,73 @@ +privateKey = $privateKey; + } + + public function toJwt(): Token + { + $config = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->privateKey->getKeyContents(), $this->privateKey->getPassPhrase() ?? ''), + InMemory::plainText('empty', 'empty') + ); + + // Whole seconds, so the JWT's dates are integers (lcobucci writes microseconds as floats). + $now = new \DateTimeImmutable('@' . time()); + $builder = $config->builder() + ->issuedBy($this->issuer) + ->permittedFor($this->getClient()->getIdentifier()) + ->identifiedBy($this->getIdentifier()) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) + ->expiresAt(new \DateTimeImmutable('@' . $this->getExpiryDateTime()->getTimestamp())) + ->relatedTo((string) $this->getUserIdentifier()) + ->withClaim('scopes', array_map(fn ($scope) => $scope->getIdentifier(), $this->getScopes())); + + if ($this->keyId) { + $builder = $builder->withHeader('kid', $this->keyId); + } + + return $builder->getToken($config->signer(), $config->signingKey()); + } + + public function __toString() + { + return $this->jwt ??= $this->toJwt()->toString(); + } +} diff --git a/src/Auth/OAuthServer/Entities/AuthCodeEntity.php b/src/Auth/OAuthServer/Entities/AuthCodeEntity.php new file mode 100644 index 00000000..c4cad06b --- /dev/null +++ b/src/Auth/OAuthServer/Entities/AuthCodeEntity.php @@ -0,0 +1,15 @@ + $redirectUris + */ + public function __construct(string $identifier, string $name, array $redirectUris, bool $confidential) + { + $this->setIdentifier($identifier); + $this->name = $name; + $this->redirectUri = $redirectUris; + $this->isConfidential = $confidential; + } +} diff --git a/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php b/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php new file mode 100644 index 00000000..eccbf7e2 --- /dev/null +++ b/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php @@ -0,0 +1,13 @@ +setIdentifier($identifier); + } +} diff --git a/src/Auth/OAuthServer/Entities/UserEntity.php b/src/Auth/OAuthServer/Entities/UserEntity.php new file mode 100644 index 00000000..102493b3 --- /dev/null +++ b/src/Auth/OAuthServer/Entities/UserEntity.php @@ -0,0 +1,19 @@ +setIdentifier($uuid); + } +} diff --git a/src/Auth/OAuthServer/Exceptions/AuthorizeError.php b/src/Auth/OAuthServer/Exceptions/AuthorizeError.php new file mode 100644 index 00000000..9730a173 --- /dev/null +++ b/src/Auth/OAuthServer/Exceptions/AuthorizeError.php @@ -0,0 +1,48 @@ +redirectUri !== null; + } + + /** + * The URL to send the user back to the app with the error, including `iss` (RFC 9207). + */ + public function redirectUrl(string $issuer): ?string + { + if (!$this->canRedirect()) { + return null; + } + + $params = array_filter([ + 'error' => $this->error, + 'error_description' => $this->description, + 'state' => $this->state, + 'iss' => $issuer, + ], fn ($value) => $value !== null && $value !== ''); + + $separator = str_contains($this->redirectUri, '?') ? '&' : '?'; + + return $this->redirectUri . $separator . http_build_query($params); + } +} diff --git a/src/Auth/OAuthServer/Exceptions/TokenError.php b/src/Auth/OAuthServer/Exceptions/TokenError.php new file mode 100644 index 00000000..38cff67a --- /dev/null +++ b/src/Auth/OAuthServer/Exceptions/TokenError.php @@ -0,0 +1,47 @@ +error . '"'; + if ($this->description) { + $challenge .= ', error_description="' . addslashes($this->description) . '"'; + } + if ($this->scope) { + $challenge .= ', scope="' . $this->scope . '"'; + } + + return new JsonResponse( + array_filter(['error' => $this->error, 'error_description' => $this->description]), + $this->status, + ['WWW-Authenticate' => $challenge, 'Cache-Control' => 'no-store'] + ); + } +} diff --git a/src/Auth/OAuthServer/IdTokenResponse.php b/src/Auth/OAuthServer/IdTokenResponse.php new file mode 100644 index 00000000..2ad36af8 --- /dev/null +++ b/src/Auth/OAuthServer/IdTokenResponse.php @@ -0,0 +1,84 @@ + $scope->getIdentifier(), $accessToken->getScopes()); + $params = ['scope' => implode(' ', $scopes)]; + + if (!in_array('openid', $scopes, true)) { + return $params; + } + + $user = User::where('uuid', $accessToken->getUserIdentifier())->first(); + if (!$user) { + return $params; + } + + $company = $this->context->companyUuid ? Company::on('mysql')->where('uuid', $this->context->companyUuid)->first() : null; + $config = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->keys->privateKeyContents()), + InMemory::plainText($this->keys->publicKeyContents()) + ); + + $now = new \DateTimeImmutable('@' . time()); + $builder = $config->builder() + ->withHeader('kid', $this->keys->keyId()) + ->issuedBy($this->issuer) + ->permittedFor($accessToken->getClient()->getIdentifier()) + ->issuedAt($now) + ->expiresAt($now->add($this->ttl)) + ->withClaim('at_hash', static::accessTokenHash((string) $accessToken)); + + foreach (OAuthClaims::forUser($user, $scopes, $company) as $claim => $value) { + $builder = $claim === 'sub' ? $builder->relatedTo($value) : $builder->withClaim($claim, $value); + } + + if ($this->context->authTime) { + $builder = $builder->withClaim('auth_time', $this->context->authTime->getTimestamp()); + } + + if ($this->context->nonce) { + $builder = $builder->withClaim('nonce', $this->context->nonce); + } + + $params['id_token'] = $builder->getToken($config->signer(), $config->signingKey())->toString(); + + return $params; + } + + /** + * OIDC Core §3.1.3.6: base64url of the left half of the SHA-256 of the access token. + */ + public static function accessTokenHash(string $accessToken): string + { + return OAuthKeys::base64url(substr(hash('sha256', $accessToken, true), 0, 16)); + } +} diff --git a/src/Auth/OAuthServer/IssueContext.php b/src/Auth/OAuthServer/IssueContext.php new file mode 100644 index 00000000..7ba689fc --- /dev/null +++ b/src/Auth/OAuthServer/IssueContext.php @@ -0,0 +1,36 @@ +grantUuid = $grantUuid; + $context->companyUuid = $companyUuid; + + return $context; + } +} diff --git a/src/Auth/OAuthServer/OAuthClaims.php b/src/Auth/OAuthServer/OAuthClaims.php new file mode 100644 index 00000000..a710f713 --- /dev/null +++ b/src/Auth/OAuthServer/OAuthClaims.php @@ -0,0 +1,50 @@ + $scopes + * + * @return array + */ + public static function forUser(User $user, array $scopes, ?Company $company = null): array + { + $claims = ['sub' => static::subject($user)]; + + if (in_array('profile', $scopes, true)) { + $claims['name'] = $user->name; + $claims['picture'] = $user->avatar_url; + $claims['zoneinfo'] = $user->timezone; + $claims['updated_at'] = $user->updated_at?->getTimestamp(); + } + + if (in_array('email', $scopes, true)) { + $claims['email'] = $user->email; + $claims['email_verified'] = $user->email_verified_at !== null; + } + + // The organization the tokens are bound to, so the app knows which one the user picked. + if ($company && OAuthScopes::requiresOrganization($scopes)) { + $claims['org_id'] = $company->public_id; + $claims['org_name'] = $company->name; + } + + return $claims; + } + + /** + * The stable identifier for the user: their public_id, which never changes or gets reused. + */ + public static function subject(User $user): string + { + return (string) ($user->public_id ?: $user->uuid); + } +} diff --git a/src/Auth/OAuthServer/OAuthKeys.php b/src/Auth/OAuthServer/OAuthKeys.php new file mode 100644 index 00000000..842ae58a --- /dev/null +++ b/src/Auth/OAuthServer/OAuthKeys.php @@ -0,0 +1,137 @@ +load('private'); + } + + public function publicKeyContents(): string + { + return $this->load('public'); + } + + public function privateKey(): CryptKey + { + return new CryptKey($this->privateKeyContents(), null, false); + } + + public function publicKey(): CryptKey + { + return new CryptKey($this->publicKeyContents(), null, false); + } + + /** + * Whether a key pair is configured. + */ + public function configured(): bool + { + try { + return $this->privateKeyContents() !== '' && $this->publicKeyContents() !== ''; + } catch (\RuntimeException $e) { + return false; + } + } + + /** + * The defuse key league encrypts code and refresh-token payloads with. + * + * A Key object (rather than a password string) skips defuse's per-call PBKDF2 stretching, + * which is meant for human passwords and costs tens of milliseconds per token. + */ + public function encryptionKey(): Key + { + $appKey = (string) config('app.key'); + if (str_starts_with($appKey, 'base64:')) { + $appKey = base64_decode(substr($appKey, 7)); + } + if ($appKey === '') { + throw new \RuntimeException('APP_KEY is not set; the OAuth provider needs it to encrypt authorization codes.'); + } + + $bytes = hash_hkdf('sha256', $appKey, Key::KEY_BYTE_SIZE, 'fleetbase-oauth-server-payloads'); + + return Key::loadFromAsciiSafeString(Encoding::saveBytesToChecksummedAsciiSafeString(Key::KEY_CURRENT_VERSION, $bytes)); + } + + /** + * The JWK for the public key, with its RFC 7638 thumbprint as `kid`. + * + * @return array + */ + public function jwk(): array + { + $details = openssl_pkey_get_details(openssl_pkey_get_public($this->publicKeyContents())); + $n = static::base64url($details['rsa']['n']); + $e = static::base64url($details['rsa']['e']); + + return [ + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'kid' => static::base64url(hash('sha256', json_encode(['e' => $e, 'kty' => 'RSA', 'n' => $n]), true)), + 'n' => $n, + 'e' => $e, + ]; + } + + public function keyId(): string + { + return $this->jwk()['kid']; + } + + /** + * Generate a new RSA key pair. + * + * @return array{private: string, public: string} + */ + public static function generate(int $bits = 4096): array + { + $resource = openssl_pkey_new(['private_key_bits' => $bits, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($resource, $private); + $public = openssl_pkey_get_details($resource)['key']; + + return ['private' => $private, 'public' => $public]; + } + + public static function keyPath(string $type): string + { + return storage_path("oauth-{$type}.key"); + } + + public static function base64url(string $bytes): string + { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } + + protected function load(string $type): string + { + $configured = str_replace('\\n', "\n", (string) config("oauth-server.{$type}_key", '')); + if (trim($configured) !== '') { + return $configured; + } + + $path = static::keyPath($type); + if (is_readable($path)) { + return (string) file_get_contents($path); + } + + throw new \RuntimeException("The OAuth provider's {$type} key is not configured. Run `php artisan oauth-server:keys` or set OAUTH_SERVER_" . strtoupper($type) . '_KEY.'); + } +} diff --git a/src/Auth/OAuthServer/OAuthOrganizations.php b/src/Auth/OAuthServer/OAuthOrganizations.php new file mode 100644 index 00000000..d23a015a --- /dev/null +++ b/src/Auth/OAuthServer/OAuthOrganizations.php @@ -0,0 +1,104 @@ +where('user_uuid', $user->uuid) + ->where('company_uuid', $company->uuid) + ->where(function ($query) { + $query->whereNull('status')->orWhere('status', 'active'); + }) + ->first(); + } + + public static function canShare(User $user, Company $company): bool + { + if (!static::userIsActive($user)) { + return false; + } + + $membership = static::membership($user, $company); + if (!$membership) { + return false; + } + + if ($user->isAdmin() || $company->owner_uuid === $user->uuid) { + return true; + } + + if (static::$administratorCheck) { + return (bool) (static::$administratorCheck)($membership); + } + + return $membership->hasRole('Administrator'); + } + + public static function userIsActive(User $user): bool + { + return ($user->status ?? 'active') !== 'inactive' && !$user->trashed(); + } + + /** + * The user's organizations for the consent screen's picker, with whether each can be shared. + * + * @return Collection + */ + public static function forPicker(User $user): Collection + { + $companies = Company::on('mysql')->whereIn('uuid', CompanyUser::on('mysql')->where('user_uuid', $user->uuid)->pluck('company_uuid')) + ->whereNotNull('owner_uuid') + ->orderBy('name') + ->get(); + + return $companies->map(fn (Company $company) => [ + 'id' => $company->public_id, + 'uuid' => $company->uuid, + 'name' => $company->name, + 'logo_url' => $company->logo_url, + 'can_share' => static::canShare($user, $company), + ])->values(); + } + + /** + * Find one of the user's organizations by public_id or uuid. + */ + public static function find(User $user, ?string $id): ?Company + { + if (!$id) { + return null; + } + + $company = Company::on('mysql')->where(fn ($query) => $query->where('public_id', $id)->orWhere('uuid', $id))->first(); + if (!$company || !static::membership($user, $company)) { + return null; + } + + return $company; + } +} diff --git a/src/Auth/OAuthServer/OAuthScopes.php b/src/Auth/OAuthServer/OAuthScopes.php new file mode 100644 index 00000000..56dca0b6 --- /dev/null +++ b/src/Auth/OAuthServer/OAuthScopes.php @@ -0,0 +1,145 @@ + "See {organization}'s storefronts and networks, with their public keys", + * 'organization' => true, + * ]); + * + * - description: the consent-screen line, in plain words. `{organization}` is replaced with the + * organization the user picked. + * - organization: true when the scope reads or changes an organization's data. A request with + * any such scope makes the user pick an organization they administer, and binds the tokens to it. + * - review_access_types: App Review subject types (Fleetbase\Support\AppReview) this scope lets + * the app turn on or off through /oauth/v1/review-access/{type}/{id}. + */ +class OAuthScopes +{ + /** + * @var array}> + */ + protected static array $scopes = []; + + /** + * Register (or replace) a scope. + */ + public static function register(string $scope, array $definition): void + { + if (!preg_match('/^[a-z][a-z0-9_.\-]*(:[a-z0-9_.\-]+)*$/', $scope)) { + throw new \InvalidArgumentException("Invalid OAuth scope name [{$scope}]."); + } + + static::$scopes[$scope] = [ + 'description' => (string) ($definition['description'] ?? $scope), + 'organization' => (bool) ($definition['organization'] ?? false), + 'review_access_types' => array_values($definition['review_access_types'] ?? []), + ]; + } + + /** + * The scopes core registers. Called from CoreServiceProvider::boot(). + */ + public static function registerDefaults(): void + { + static::register('openid', ['description' => 'Sign you in with your Fleetbase account']); + static::register('profile', ['description' => 'See your name and profile photo']); + static::register('email', ['description' => 'See your email address']); + static::register('organization:read', [ + 'description' => "See {organization}'s name, logo, currency and country", + 'organization' => true, + ]); + static::register('api-key:create', [ + 'description' => 'Create an API key for this app in {organization}', + 'organization' => true, + ]); + } + + public static function has(string $scope): bool + { + return isset(static::$scopes[$scope]); + } + + /** + * @return array}> + */ + public static function all(): array + { + return static::$scopes; + } + + public static function get(string $scope): ?array + { + return static::$scopes[$scope] ?? null; + } + + /** + * Whether any of the scopes needs an organization. + * + * @param array $scopes + */ + public static function requiresOrganization(array $scopes): bool + { + foreach ($scopes as $scope) { + if (static::$scopes[$scope]['organization'] ?? false) { + return true; + } + } + + return false; + } + + /** + * The scope that covers App Review access for a subject type, if any. + */ + public static function scopeForReviewAccessType(string $type): ?string + { + foreach (static::$scopes as $scope => $definition) { + if (in_array($type, $definition['review_access_types'], true)) { + return $scope; + } + } + + return null; + } + + /** + * The consent-screen lines for a set of scopes. + * + * @param array $scopes + * + * @return array + */ + public static function describe(array $scopes, ?string $organizationName = null): array + { + $lines = []; + foreach ($scopes as $scope) { + $definition = static::$scopes[$scope] ?? null; + if (!$definition) { + continue; + } + + $lines[] = [ + 'scope' => $scope, + 'description' => str_replace('{organization}', $organizationName ?: 'your organization', $definition['description']), + 'organization' => $definition['organization'], + ]; + } + + return $lines; + } + + /** + * Forget every registration. For tests. + */ + public static function flush(): void + { + static::$scopes = []; + } +} diff --git a/src/Auth/OAuthServer/OAuthServer.php b/src/Auth/OAuthServer/OAuthServer.php new file mode 100644 index 00000000..8902d1a6 --- /dev/null +++ b/src/Auth/OAuthServer/OAuthServer.php @@ -0,0 +1,604 @@ +keys ??= new OAuthKeys(); + } + + public function keys(): OAuthKeys + { + return $this->keys; + } + + /** + * The issuer identifier, without a trailing slash. + */ + public function issuer(): string + { + $issuer = config('oauth-server.issuer') ?: config('app.url'); + + return rtrim((string) $issuer, '/'); + } + + public function endpoint(string $path): string + { + return $this->issuer() . '/' . ltrim($path, '/'); + } + + public function ttl(string $name): \DateInterval + { + return new \DateInterval((string) config("oauth-server.ttl.{$name}", ['auth_code' => 'PT10M', 'access_token' => 'PT1H', 'refresh_token' => 'P30D', 'id_token' => 'PT1H'][$name])); + } + + /** + * League's authorization server, wired to Fleetbase's repositories for one request. + */ + public function authorizationServer(IssueContext $context): AuthorizationServer + { + $server = new AuthorizationServer( + new ClientRepository(), + new AccessTokenRepository($context, $this->issuer(), $this->keys->keyId()), + new ScopeRepository(), + $this->keys->privateKey(), + $this->keys->encryptionKey(), + new IdTokenResponse($context, $this->keys, $this->issuer(), $this->ttl('id_token')) + ); + + $authCodeGrant = new AuthCodeGrant(new AuthCodeRepository($context), new RefreshTokenRepository($context), $this->ttl('auth_code')); + $authCodeGrant->setRefreshTokenTTL($this->ttl('refresh_token')); + $server->enableGrantType($authCodeGrant, $this->ttl('access_token')); + + $refreshTokenGrant = new RefreshTokenGrant(new RefreshTokenRepository($context)); + $refreshTokenGrant->setRefreshTokenTTL($this->ttl('refresh_token')); + $server->enableGrantType($refreshTokenGrant, $this->ttl('access_token')); + + return $server; + } + + public function resourceServer(): ResourceServer + { + return new ResourceServer(new AccessTokenRepository(new IssueContext(), $this->issuer()), $this->keys->publicKey()); + } + + /* + |-------------------------------------------------------------------------- + | Authorization (the consent screen) + |-------------------------------------------------------------------------- + */ + + /** + * Check an authorization request's parameters. + * + * @param array $params the authorization request's query parameters + * + * @throws AuthorizeError + */ + public function validateAuthorizationRequest(array $params): ValidatedAuthorization + { + $string = fn (string $key) => is_string($params[$key] ?? null) && $params[$key] !== '' ? $params[$key] : null; + + $client = (new ClientRepository())->findActive($string('client_id')); + if (!$client) { + throw new AuthorizeError('invalid_client', 'This app is not registered with Fleetbase, or it has been turned off.'); + } + + $redirectUri = $string('redirect_uri'); + if (!$client->allowsRedirectUri($redirectUri)) { + throw new AuthorizeError('invalid_request', "The redirect URI doesn't match one registered for {$client->name}."); + } + + // From here on the redirect URI is trusted, so errors go back to the app. + $state = $string('state'); + $fail = fn (string $error, string $description) => new AuthorizeError($error, $description, $redirectUri, $state); + + foreach ($params as $key => $value) { + if (!is_string($value)) { + throw $fail('invalid_request', "The parameter `{$key}` must be a single value."); + } + } + + if ($string('response_type') !== 'code') { + throw $fail('unsupported_response_type', 'Only response_type=code is supported.'); + } + + if (!$string('code_challenge')) { + throw $fail('invalid_request', 'PKCE is required: send code_challenge and code_challenge_method=S256.'); + } + + if ($string('code_challenge_method') !== 'S256') { + throw $fail('invalid_request', 'code_challenge_method must be S256.'); + } + + $scopes = array_values(array_unique(array_filter(explode(' ', (string) $string('scope'))))); + if (!$scopes) { + throw $fail('invalid_scope', 'Ask for at least one scope.'); + } + foreach ($scopes as $scope) { + if (!OAuthScopes::has($scope) || !$client->allowsScope($scope)) { + throw $fail('invalid_scope', "The scope `{$scope}` isn't available to this app."); + } + } + + $prompt = array_values(array_filter(explode(' ', (string) $string('prompt')))); + if (in_array('none', $prompt, true)) { + // There is no silent authorization: the consent screen runs in the console, which + // the app can't check without showing it. + throw $fail(count($prompt) > 1 ? 'invalid_request' : 'interaction_required', 'prompt=none is not supported; the user has to see the consent screen.'); + } + + $nonce = $string('nonce'); + if ($nonce !== null && strlen($nonce) > 255) { + throw $fail('invalid_request', 'nonce must be at most 255 characters.'); + } + + $maxAge = $string('max_age'); + if ($maxAge !== null && !ctype_digit($maxAge)) { + throw $fail('invalid_request', 'max_age must be a whole number of seconds.'); + } + + try { + $request = $this->authorizationServer(new IssueContext())->validateAuthorizationRequest($this->queryRequest($params)); + } catch (OAuthServerException $e) { + throw $fail($e->getErrorType(), $e->getHint() ?: $e->getMessage()); + } + + return new ValidatedAuthorization($client, $request, $scopes, $redirectUri, $state, $nonce, $prompt, $maxAge !== null ? (int) $maxAge : null); + } + + /** + * Whether the user has to sign in again first (`prompt=login`, or `max_age` exceeded). + */ + public function needsReauthentication(ValidatedAuthorization $authorization, ?\DateTimeInterface $authTime): bool + { + if (!$authTime) { + return $authorization->prompts('login') || $authorization->maxAge !== null; + } + + $age = time() - $authTime->getTimestamp(); + if ($authorization->prompts('login') && $age > (int) config('oauth-server.reauthenticate_after', 300)) { + return true; + } + + return $authorization->maxAge !== null && $age > $authorization->maxAge; + } + + /** + * The user's live grant for this app and organization, if any. + */ + public function existingGrant(User $user, OAuthClient $client, ?Company $company): ?OAuthGrant + { + return OAuthGrant::active() + ->where('user_uuid', $user->uuid) + ->where('client_id', $client->id) + ->where(fn ($query) => $company ? $query->where('company_uuid', $company->uuid) : $query->whereNull('company_uuid')) + ->first(); + } + + /** + * Whether the consent screen can be skipped: sign-in only, already approved, not forced. + */ + public function canSkipConsent(ValidatedAuthorization $authorization, User $user): bool + { + if ($authorization->requiresOrganization() || $authorization->prompts('consent')) { + return false; + } + + $grant = $this->existingGrant($user, $authorization->client, null); + + return $grant && empty(array_diff($authorization->scopes, $grant->scopes ?? [])); + } + + /** + * The user approved: record the grant, issue the code and return where to send the browser. + * + * @return array{redirect: string, grant: OAuthGrant, created: bool} + * + * @throws AuthorizeError when the organization can't be shared + */ + public function approve(ValidatedAuthorization $authorization, User $user, ?Company $company, ?\DateTimeInterface $authTime): array + { + if ($authorization->requiresOrganization()) { + if (!$company || !OAuthOrganizations::canShare($user, $company)) { + throw new AuthorizeError('access_denied', 'Choose an organization you administer to share with this app.'); + } + } else { + $company = null; + } + + [$grant, $created] = DB::connection((new OAuthGrant())->getConnectionName())->transaction(function () use ($authorization, $user, $company) { + $grant = $this->existingGrant($user, $authorization->client, $company); + $created = $grant === null; + $grant ??= new OAuthGrant([ + 'user_uuid' => $user->uuid, + 'client_id' => $authorization->client->id, + 'company_uuid' => $company?->uuid, + ]); + $grant->scopes = array_values(array_unique(array_merge($grant->scopes ?? [], $authorization->scopes))); + $grant->save(); + + return [$grant, $created]; + }); + + $context = IssueContext::forGrant($grant->uuid, $company?->uuid); + $context->nonce = $authorization->nonce; + $context->authTime = $authTime; + + $authorization->request->setUser(new UserEntity($user->uuid)); + $authorization->request->setAuthorizationApproved(true); + + $response = $this->authorizationServer($context)->completeAuthorizationRequest($authorization->request, new Psr7Response()); + + return [ + 'redirect' => $this->withIssuer($response->getHeaderLine('Location')), + 'grant' => $grant, + 'created' => $created, + ]; + } + + /** + * The user declined: where to send the browser. + */ + public function deny(ValidatedAuthorization $authorization): string + { + return (new AuthorizeError('access_denied', 'The user declined to share access.', $authorization->redirectUri, $authorization->state))->redirectUrl($this->issuer()); + } + + /* + |-------------------------------------------------------------------------- + | Token endpoint + |-------------------------------------------------------------------------- + */ + + public function respondToTokenRequest(Request $request): Response + { + $context = new IssueContext(); + + try { + $this->loadTokenContext($request->request->all(), $context); + $response = $this->authorizationServer($context)->respondToAccessTokenRequest($this->psrRequest($request), new Psr7Response()); + + if ($context->grantUuid) { + OAuthGrant::find($context->grantUuid)?->touchLastUsed(); + } + } catch (OAuthServerException $e) { + $response = $e->generateHttpResponse(new Psr7Response()); + } + + return $this->laravelResponse($response); + } + + /** + * Find the grant and organization behind the code or refresh token being redeemed. + * + * @param array $params + * + * @throws OAuthServerException when the grant no longer allows new tokens + */ + protected function loadTokenContext(array $params, IssueContext $context): void + { + $crypt = new PayloadCrypt($this->keys->encryptionKey()); + + if (($params['grant_type'] ?? null) === 'authorization_code') { + $payload = $crypt->open($params['code'] ?? null); + $code = isset($payload['auth_code_id']) ? OAuthAuthCode::find($payload['auth_code_id']) : null; + if (!$code) { + return; + } + + $context->grantUuid = $code->grant_uuid; + $context->companyUuid = $code->company_uuid; + $context->nonce = $code->nonce; + $context->authTime = $code->auth_time; + $context->authCodeId = $code->id; + + // A replayed code is left to league, whose revoked-code check also revokes its tokens. + if (!$code->revoked) { + $this->assertGrantUsable(OAuthGrant::find($code->grant_uuid)); + } + + return; + } + + if (($params['grant_type'] ?? null) === 'refresh_token') { + $payload = $crypt->open($params['refresh_token'] ?? null); + $refreshToken = isset($payload['refresh_token_id']) ? OAuthRefreshToken::find($payload['refresh_token_id']) : null; + if (!$refreshToken) { + return; + } + + $grant = OAuthGrant::find($refreshToken->grant_uuid); + $context->grantUuid = $refreshToken->grant_uuid; + $context->companyUuid = $grant?->company_uuid; + $context->authCodeId = OAuthAccessToken::where('id', $refreshToken->access_token_id)->value('auth_code_id'); + + // A replayed refresh token is left to league, whose revoked check revokes the grant. + if (!$refreshToken->revoked) { + $this->assertGrantUsable($grant); + } + } + } + + /** + * @throws OAuthServerException + */ + protected function assertGrantUsable(?OAuthGrant $grant): void + { + if (!$grant || $grant->isRevoked()) { + throw OAuthServerException::invalidGrant('The user removed this app\'s access.'); + } + + $user = User::where('uuid', $grant->user_uuid)->first(); + if (!$user || !OAuthOrganizations::userIsActive($user)) { + $grant->revoke(); + + throw OAuthServerException::invalidGrant('The account that authorized this app is no longer active.'); + } + + if ($grant->company_uuid) { + $company = Company::on('mysql')->where('uuid', $grant->company_uuid)->first(); + if (!$company || !OAuthOrganizations::canShare($user, $company)) { + $grant->revoke(); + + throw OAuthServerException::invalidGrant('The user no longer administers the organization this app was given.'); + } + } + } + + /* + |-------------------------------------------------------------------------- + | Revocation (RFC 7009) + |-------------------------------------------------------------------------- + */ + + /** + * Revoke a refresh token (which disconnects the app: the whole grant) or an access token. + * + * Answers 200 whether or not the token was known, as RFC 7009 §2.2 asks. + */ + public function revoke(Request $request): Response|JsonResponse + { + [$clientId, $secret] = $this->clientCredentials($request); + $client = (new ClientRepository())->findActive($clientId); + $authenticated = $client && ($client->isConfidential() ? $client->verifySecret($secret) : ($secret === null || $secret === '')); + if (!$authenticated) { + return new JsonResponse(['error' => 'invalid_client', 'error_description' => 'Client authentication failed.'], 401, ['WWW-Authenticate' => 'Basic realm="OAuth"', 'Cache-Control' => 'no-store']); + } + + $token = $request->input('token'); + if (!is_string($token) || $token === '') { + return new JsonResponse(['error' => 'invalid_request', 'error_description' => 'Send the token to revoke.'], 400, ['Cache-Control' => 'no-store']); + } + + $payload = (new PayloadCrypt($this->keys->encryptionKey()))->open($token); + if ($payload && ($payload['client_id'] ?? null) === $client->id && isset($payload['refresh_token_id'])) { + $refreshToken = OAuthRefreshToken::find($payload['refresh_token_id']); + if ($refreshToken) { + OAuthGrant::find($refreshToken->grant_uuid)?->revoke(); + } + } elseif ($tokenId = $this->accessTokenId($token)) { + OAuthAccessToken::where('id', $tokenId)->where('client_id', $client->id)->update(['revoked' => true, 'updated_at' => now()]); + } + + return new Response('', 200, ['Cache-Control' => 'no-store']); + } + + /** + * The `jti` of an access token this server signed, or null. + */ + protected function accessTokenId(string $jwt): ?string + { + try { + $config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText('empty', 'empty')); + $token = $config->parser()->parse($jwt); + if (!$config->validator()->validate($token, new SignedWith(new Sha256(), InMemory::plainText($this->keys->publicKeyContents())))) { + return null; + } + + return $token->claims()->get('jti'); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Client credentials from HTTP Basic (client_secret_basic) or the body (client_secret_post). + * + * @return array{0: ?string, 1: ?string} + */ + protected function clientCredentials(Request $request): array + { + $header = (string) $request->header('Authorization'); + if (stripos($header, 'Basic ') === 0) { + $decoded = base64_decode(substr($header, 6), true); + if ($decoded !== false && str_contains($decoded, ':')) { + [$id, $secret] = explode(':', $decoded, 2); + + return [urldecode($id), urldecode($secret)]; + } + } + + $id = $request->input('client_id'); + $secret = $request->input('client_secret'); + + return [is_string($id) ? $id : null, is_string($secret) ? $secret : null]; + } + + /* + |-------------------------------------------------------------------------- + | Protected resources + |-------------------------------------------------------------------------- + */ + + /** + * Check the bearer token on a request to an OAuth-protected endpoint. + * + * @param array $scopes every scope the endpoint needs + * + * @throws TokenError + */ + public function authenticate(Request $request, array $scopes = []): OAuthAccessToken + { + try { + $validated = $this->resourceServer()->validateAuthenticatedRequest($this->psrRequest($request)); + } catch (OAuthServerException $e) { + throw TokenError::invalidToken(); + } + + $token = OAuthAccessToken::with(['grant', 'client', 'user', 'company'])->find($validated->getAttribute('oauth_access_token_id')); + if (!$token || !$token->isUsable() || !$token->grant || $token->grant->isRevoked() || !$token->client || $token->client->isDisabled()) { + throw TokenError::invalidToken(); + } + + if (!$token->user || !OAuthOrganizations::userIsActive($token->user)) { + throw TokenError::invalidToken(); + } + + if ($token->company_uuid && (!$token->company || !OAuthOrganizations::canShare($token->user, $token->company))) { + $token->grant->revoke(); + + throw TokenError::invalidToken('The user no longer administers the organization this app was given.'); + } + + // The client's registration is checked too, so an admin removing a scope from an app + // takes it away from tokens already issued. + foreach ($scopes as $scope) { + if (!$token->hasScope($scope) || !$token->client->allowsScope($scope)) { + throw TokenError::insufficientScope($scope); + } + } + + $token->grant->touchLastUsed(); + + return $token; + } + + /* + |-------------------------------------------------------------------------- + | Discovery + |-------------------------------------------------------------------------- + */ + + /** + * OpenID Connect Discovery 1.0 / RFC 8414 metadata. + * + * @return array + */ + public function discovery(): array + { + $registration = config('oauth-server.registration.enabled', true) ? ['registration_endpoint' => $this->endpoint('oauth/register')] : []; + + return $registration + [ + 'issuer' => $this->issuer(), + 'authorization_endpoint' => $this->endpoint('oauth/authorize'), + 'token_endpoint' => $this->endpoint('oauth/token'), + 'userinfo_endpoint' => $this->endpoint('oauth/userinfo'), + 'jwks_uri' => $this->endpoint('oauth/jwks'), + 'revocation_endpoint' => $this->endpoint('oauth/revoke'), + 'scopes_supported' => array_keys(OAuthScopes::all()), + 'response_types_supported' => ['code'], + 'response_modes_supported' => ['query'], + 'grant_types_supported' => ClientRepository::GRANT_TYPES, + 'subject_types_supported' => ['public'], + 'id_token_signing_alg_values_supported' => ['RS256'], + 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], + 'revocation_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], + 'code_challenge_methods_supported' => ['S256'], + 'prompt_values_supported' => ['login', 'consent'], + 'claims_supported' => ['sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash', 'name', 'picture', 'zoneinfo', 'updated_at', 'email', 'email_verified', 'org_id', 'org_name'], + 'authorization_response_iss_parameter_supported' => true, + ]; + } + + /* + |-------------------------------------------------------------------------- + | PSR-7 plumbing + |-------------------------------------------------------------------------- + */ + + public function psrRequest(Request $request): ServerRequestInterface + { + $factory = new Psr17Factory(); + + return (new PsrHttpFactory($factory, $factory, $factory, $factory))->createRequest($request); + } + + /** + * A GET request carrying only these query parameters, for league's authorization checks. + * + * @param array $params + */ + protected function queryRequest(array $params): ServerRequestInterface + { + return (new ServerRequest('GET', $this->endpoint('oauth/authorize') . '?' . http_build_query($params)))->withQueryParams($params); + } + + public function laravelResponse(ResponseInterface $response): Response + { + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return new Response((string) $response->getBody(), $response->getStatusCode(), $headers); + } + + /** + * Add `iss` to an authorization response (RFC 9207), so the app can detect mix-up attacks. + */ + protected function withIssuer(string $location): string + { + return $location . (str_contains($location, '?') ? '&' : '?') . 'iss=' . rawurlencode($this->issuer()); + } +} diff --git a/src/Auth/OAuthServer/PayloadCrypt.php b/src/Auth/OAuthServer/PayloadCrypt.php new file mode 100644 index 00000000..302ee449 --- /dev/null +++ b/src/Auth/OAuthServer/PayloadCrypt.php @@ -0,0 +1,41 @@ +setEncryptionKey($key); + } + + /** + * @return array|null + */ + public function open(mixed $payload): ?array + { + if (!is_string($payload) || $payload === '') { + return null; + } + + try { + $decoded = json_decode($this->decrypt($payload), true); + } catch (\Throwable $e) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } +} diff --git a/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php b/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php new file mode 100644 index 00000000..218160b7 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php @@ -0,0 +1,60 @@ +issuer, $this->keyId); + $token->setClient($clientEntity); + $token->setUserIdentifier($userIdentifier); + foreach ($scopes as $scope) { + $token->addScope($scope); + } + + return $token; + } + + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) + { + if (!$this->context->grantUuid) { + throw new \LogicException('An access token can only be issued under a grant.'); + } + + OAuthAccessToken::create([ + 'id' => $accessTokenEntity->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'client_id' => $accessTokenEntity->getClient()->getIdentifier(), + 'user_uuid' => $accessTokenEntity->getUserIdentifier(), + 'company_uuid' => $this->context->companyUuid, + 'auth_code_id' => $this->context->authCodeId, + 'scopes' => array_map(fn ($scope) => $scope->getIdentifier(), $accessTokenEntity->getScopes()), + 'revoked' => false, + 'expires_at' => $accessTokenEntity->getExpiryDateTime(), + ]); + } + + public function revokeAccessToken($tokenId) + { + OAuthAccessToken::where('id', $tokenId)->update(['revoked' => true, 'updated_at' => now()]); + } + + public function isAccessTokenRevoked($tokenId) + { + $token = OAuthAccessToken::find($tokenId); + + return !$token || $token->revoked; + } +} diff --git a/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php b/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php new file mode 100644 index 00000000..4da965c9 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php @@ -0,0 +1,73 @@ +context->grantUuid) { + throw new \LogicException('An authorization code can only be issued under a grant.'); + } + + OAuthAuthCode::create([ + 'id' => $authCodeEntity->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'client_id' => $authCodeEntity->getClient()->getIdentifier(), + 'user_uuid' => $authCodeEntity->getUserIdentifier(), + 'company_uuid' => $this->context->companyUuid, + 'scopes' => array_map(fn ($scope) => $scope->getIdentifier(), $authCodeEntity->getScopes()), + 'nonce' => $this->context->nonce, + 'auth_time' => $this->context->authTime, + 'revoked' => false, + 'expires_at' => $authCodeEntity->getExpiryDateTime(), + 'created_at' => now(), + ]); + } + + public function revokeAuthCode($codeId) + { + OAuthAuthCode::where('id', $codeId)->update(['revoked' => true]); + } + + /** + * A code that was already redeemed is being presented again. RFC 6749 §4.1.2: the server + * SHOULD revoke every token issued from it, since one of the two holders is an attacker. + */ + public function isAuthCodeRevoked($codeId) + { + $code = OAuthAuthCode::find($codeId); + if (!$code) { + return true; + } + + if ($code->revoked) { + $tokenIds = OAuthAccessToken::where('auth_code_id', $codeId)->pluck('id')->all(); + if ($tokenIds) { + OAuthAccessToken::whereIn('id', $tokenIds)->update(['revoked' => true, 'updated_at' => now()]); + OAuthRefreshToken::whereIn('access_token_id', $tokenIds)->update(['revoked' => true]); + } + + return true; + } + + return false; + } +} diff --git a/src/Auth/OAuthServer/Repositories/ClientRepository.php b/src/Auth/OAuthServer/Repositories/ClientRepository.php new file mode 100644 index 00000000..d4ed7ff8 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/ClientRepository.php @@ -0,0 +1,49 @@ +findActive($clientIdentifier); + if (!$client) { + return null; + } + + return new ClientEntity($client->id, $client->name, $client->redirect_uris ?? [], $client->isConfidential()); + } + + public function validateClient($clientIdentifier, $clientSecret, $grantType) + { + $client = $this->findActive($clientIdentifier); + if (!$client || !in_array($grantType, static::GRANT_TYPES, true)) { + return false; + } + + if ($client->isConfidential()) { + return $client->verifySecret($clientSecret); + } + + // A public client has no secret to present; sending one means it was misconfigured. + return $clientSecret === null || $clientSecret === ''; + } + + public function findActive($clientIdentifier): ?OAuthClient + { + if (!is_string($clientIdentifier) || !preg_match('/^[0-9a-f-]{36}$/i', $clientIdentifier)) { + return null; + } + + return OAuthClient::where('id', $clientIdentifier)->whereNull('disabled_at')->first(); + } +} diff --git a/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php b/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php new file mode 100644 index 00000000..a6677ba6 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php @@ -0,0 +1,67 @@ +context->grantUuid) { + throw new \LogicException('A refresh token can only be issued under a grant.'); + } + + OAuthRefreshToken::create([ + 'id' => $refreshTokenEntity->getIdentifier(), + 'access_token_id' => $refreshTokenEntity->getAccessToken()->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'revoked' => false, + 'expires_at' => $refreshTokenEntity->getExpiryDateTime(), + 'created_at' => now(), + ]); + } + + public function revokeRefreshToken($tokenId) + { + OAuthRefreshToken::where('id', $tokenId)->update(['revoked' => true]); + } + + /** + * Refresh tokens rotate on every use, so a revoked one coming back means it was copied: either + * the client or a thief already used it. Revoke the whole grant so neither copy keeps working + * (OAuth 2.0 Security BCP, RFC 9700 §4.14.2). + */ + public function isRefreshTokenRevoked($tokenId) + { + $token = OAuthRefreshToken::find($tokenId); + if (!$token) { + return true; + } + + if ($token->revoked) { + $grant = OAuthGrant::find($token->grant_uuid); + if ($grant && !$grant->isRevoked()) { + $grant->revoke(); + } + + return true; + } + + return false; + } +} diff --git a/src/Auth/OAuthServer/Repositories/ScopeRepository.php b/src/Auth/OAuthServer/Repositories/ScopeRepository.php new file mode 100644 index 00000000..c84be8a9 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/ScopeRepository.php @@ -0,0 +1,36 @@ +getIdentifier()); + if (!$client) { + return []; + } + + return array_values(array_filter($scopes, function ($scope) use ($client) { + return OAuthScopes::has($scope->getIdentifier()) && $client->allowsScope($scope->getIdentifier()); + })); + } +} diff --git a/src/Auth/OAuthServer/ValidatedAuthorization.php b/src/Auth/OAuthServer/ValidatedAuthorization.php new file mode 100644 index 00000000..bafd601a --- /dev/null +++ b/src/Auth/OAuthServer/ValidatedAuthorization.php @@ -0,0 +1,38 @@ + $scopes + * @param array $prompt + */ + public function __construct( + public OAuthClient $client, + public AuthorizationRequest $request, + public array $scopes, + public string $redirectUri, + public ?string $state, + public ?string $nonce, + public array $prompt, + public ?int $maxAge, + ) { + } + + public function requiresOrganization(): bool + { + return OAuthScopes::requiresOrganization($this->scopes); + } + + public function prompts(string $value): bool + { + return in_array($value, $this->prompt, true); + } +} diff --git a/src/Console/Commands/OAuthServerClient.php b/src/Console/Commands/OAuthServerClient.php new file mode 100644 index 00000000..199e048f --- /dev/null +++ b/src/Console/Commands/OAuthServerClient.php @@ -0,0 +1,120 @@ +argument('action')) { + 'create' => $this->create(), + 'rotate' => $this->rotate(), + 'list' => $this->list(), + default => $this->refuse('Unknown action; use create, rotate or list.'), + }; + } + + protected function create(): int + { + $name = $this->option('name'); + $redirects = $this->option('redirect'); + $scopes = $this->option('scope') ?: array_keys(OAuthScopes::all()); + if (!$name || !$redirects) { + return $this->refuse('--name and at least one --redirect are required.'); + } + + foreach ($redirects as $uri) { + if (!OAuthClient::isAcceptableRedirectUri($uri, (bool) $this->option('development'))) { + return $this->refuse("`{$uri}` must be https (http only for loopback on a --development app), without a fragment."); + } + } + foreach ($scopes as $scope) { + if (!OAuthScopes::has($scope)) { + return $this->refuse("`{$scope}` is not a registered scope."); + } + } + + $client = new OAuthClient([ + 'name' => $name, + 'redirect_uris' => array_values(array_unique($redirects)), + 'allowed_scopes' => array_values(array_unique($scopes)), + 'website_url' => $this->option('website'), + 'logo_url' => $this->option('logo'), + 'privacy_url' => $this->option('privacy'), + 'terms_url' => $this->option('terms'), + 'official' => (bool) $this->option('official'), + 'development' => (bool) $this->option('development'), + ]); + $secret = $this->option('public') ? null : $client->issueSecret(); + $client->save(); + + $this->info('Registered ' . $client->name . '.'); + $this->line('client_id: ' . $client->id); + if ($secret) { + $this->line('client_secret: ' . $secret); + $this->warn('Store the secret now; it is not shown again.'); + } + + return self::SUCCESS; + } + + protected function rotate(): int + { + $client = OAuthClient::find($this->argument('id')); + if (!$client || !$client->isConfidential()) { + return $this->refuse('No confidential app with that client id.'); + } + + $secret = $client->issueSecret(); + $client->save(); + + $this->line('client_secret: ' . $secret); + $this->warn('The old secret stopped working. Store the new one now; it is not shown again.'); + + return self::SUCCESS; + } + + protected function list(): int + { + $this->table(['client_id', 'name', 'type', 'redirect URIs', 'status'], OAuthClient::orderBy('name')->get()->map(fn (OAuthClient $client) => [ + $client->id, + $client->name, + $client->isConfidential() ? 'confidential' : 'public', + implode("\n", $client->redirect_uris ?? []), + $client->isDisabled() ? 'disabled' : 'active', + ])->all()); + + return self::SUCCESS; + } + + protected function refuse(string $message): int + { + $this->error($message); + + return self::FAILURE; + } +} diff --git a/src/Console/Commands/OAuthServerKeys.php b/src/Console/Commands/OAuthServerKeys.php new file mode 100644 index 00000000..cf141c97 --- /dev/null +++ b/src/Console/Commands/OAuthServerKeys.php @@ -0,0 +1,47 @@ +option('show')) { + $this->line('OAUTH_SERVER_PRIVATE_KEY="' . str_replace("\n", '\\n', trim($keys['private'])) . '"'); + $this->line('OAUTH_SERVER_PUBLIC_KEY="' . str_replace("\n", '\\n', trim($keys['public'])) . '"'); + + return self::SUCCESS; + } + + $private = OAuthKeys::keyPath('private'); + $public = OAuthKeys::keyPath('public'); + if ((file_exists($private) || file_exists($public)) && !$this->option('force')) { + $this->error('Keys already exist. Rotating them signs everyone out of every app; use --force to do it anyway.'); + + return self::FAILURE; + } + + file_put_contents($private, $keys['private']); + file_put_contents($public, $keys['public']); + @chmod($private, 0600); + @chmod($public, 0644); + + $this->info('Wrote ' . $private . ' and ' . $public . '.'); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/OAuthServerPrune.php b/src/Console/Commands/OAuthServerPrune.php new file mode 100644 index 00000000..fb351074 --- /dev/null +++ b/src/Console/Commands/OAuthServerPrune.php @@ -0,0 +1,45 @@ +subDays(max(0, (int) $this->option('days'))); + + $codes = OAuthAuthCode::where('expires_at', '<', $before)->delete(); + $refresh = OAuthRefreshToken::where('expires_at', '<', $before)->delete(); + $access = OAuthAccessToken::where('expires_at', '<', $before) + ->whereNotIn('id', OAuthRefreshToken::select('access_token_id')) + ->delete(); + + $clients = OAuthClient::whereNotNull('software_id') + ->where('created_at', '<', now()->subHours(max(1, (int) config('oauth-server.registration.unused_client_hours', 24)))) + ->whereDoesntHave('grants') + ->delete(); + + $this->info("Deleted {$codes} codes, {$access} access tokens, {$refresh} refresh tokens and {$clients} unused registered clients."); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/RegisterHostedRoutingCommand.php b/src/Console/Commands/RegisterHostedRoutingCommand.php new file mode 100644 index 00000000..ea3bee79 --- /dev/null +++ b/src/Console/Commands/RegisterHostedRoutingCommand.php @@ -0,0 +1,51 @@ +line('Fleetbase Hosted Routing: routes, distances and ETAs are computed on Fleetbase servers in Singapore (DigitalOcean sgp1).'); + $this->line('This sends the install ID, the admin email and this install\'s URL to Fleetbase Cloud. Route coordinates are sent with each routing request and are not logged.'); + + $result = HostedRouting::register($this->option('email') ?: null, $this->option('domain') ?: null, (bool) $this->option('force')); + + if ($result['status'] === HostedRouting::STATUS_FAILED) { + $this->error($result['message']); + + return self::FAILURE; + } + + if ($result['status'] === HostedRouting::STATUS_SKIPPED) { + $this->info($result['message']); + + return self::SUCCESS; + } + + $this->info($result['message'] . ' Plan: ' . ($result['plan'] ?? 'unknown') . '.'); + if (!$result['email_verified']) { + $this->line('Confirm the email sent to ' . $result['email'] . ' to raise the limit from 500 to 2,500 routes a day.'); + } + if (!empty($result['claim_url'])) { + $this->line('Upgrade later from Admin → Fleet-Ops → Routing, or at ' . $result['claim_url']); + } + + return self::SUCCESS; + } +} diff --git a/src/Http/Controllers/Api/v1/WebhookEndpointController.php b/src/Http/Controllers/Api/v1/WebhookEndpointController.php new file mode 100644 index 00000000..988e6fb6 --- /dev/null +++ b/src/Http/Controllers/Api/v1/WebhookEndpointController.php @@ -0,0 +1,142 @@ + session('company'), + 'api_credential_uuid' => session('api_credential'), + 'url' => $request->input('url'), + 'events' => $request->input('events') ?: [], + 'description' => $request->input('description'), + 'version' => $request->input('version') ?: $this->defaultVersion(), + 'mode' => $this->environment(), + 'status' => $request->input('status', 'enabled'), + ]); + + return response()->json($this->present($endpoint, true), 201); + } + + public function query(Request $request): JsonResponse + { + $limit = max(1, min((int) $request->input('limit', 30), 100)); + + $endpoints = $this->scoped() + ->when($request->filled('url'), fn ($query) => $query->where('url', $request->input('url'))) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + return response()->json($endpoints->map(fn (WebhookEndpoint $endpoint) => $this->present($endpoint))->values()); + } + + public function find(string $id): JsonResponse + { + $endpoint = $this->scoped()->where('uuid', $id)->first(); + if (!$endpoint) { + return response()->json(['error' => 'Webhook endpoint not found.'], 404); + } + + return response()->json($this->present($endpoint)); + } + + public function update(string $id, WebhookEndpointRequest $request): JsonResponse + { + $endpoint = $this->scoped()->where('uuid', $id)->first(); + if (!$endpoint) { + return response()->json(['error' => 'Webhook endpoint not found.'], 404); + } + + $endpoint->fill($request->only(['url', 'events', 'description', 'version', 'status'])); + $endpoint->save(); + + return response()->json($this->present($endpoint)); + } + + public function rotateSecret(string $id): JsonResponse + { + $endpoint = $this->scoped()->where('uuid', $id)->first(); + if (!$endpoint) { + return response()->json(['error' => 'Webhook endpoint not found.'], 404); + } + + $endpoint->rotateSecret(); + + return response()->json($this->present($endpoint, true)); + } + + public function delete(string $id): JsonResponse + { + $endpoint = $this->scoped()->where('uuid', $id)->first(); + if (!$endpoint) { + return response()->json(['error' => 'Webhook endpoint not found.'], 404); + } + + $endpoint->delete(); + + return response()->json(['id' => $id, 'deleted' => true]); + } + + /** + * Endpoints of the key's company in the key's environment. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function scoped() + { + return WebhookEndpoint::query() + ->where('company_uuid', session('company')) + ->where('mode', $this->environment()); + } + + protected function environment(): string + { + return session('api_environment') === 'test' ? 'test' : 'live'; + } + + protected function defaultVersion(): ?string + { + $versions = config('api.versions'); + + return is_array($versions) && count($versions) ? (string) end($versions) : null; + } + + /** + * @return array + */ + protected function present(WebhookEndpoint $endpoint, bool $withSecret = false): array + { + $data = [ + 'id' => $endpoint->uuid, + 'url' => $endpoint->url, + 'events' => $endpoint->events ?: [], + 'description' => $endpoint->description, + 'version' => $endpoint->version, + 'mode' => $endpoint->mode, + 'status' => $endpoint->status, + 'created_at' => $endpoint->created_at, + 'updated_at' => $endpoint->updated_at, + ]; + + if ($withSecret) { + $data['secret'] = $endpoint->signingSecret(); + } + + return $data; + } +} diff --git a/src/Http/Controllers/Internal/v1/ConnectedAppController.php b/src/Http/Controllers/Internal/v1/ConnectedAppController.php new file mode 100644 index 00000000..cb06c9da --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ConnectedAppController.php @@ -0,0 +1,56 @@ +where('user_uuid', $request->user()->uuid) + ->with(['client', 'company:uuid,public_id,name']) + ->latest('updated_at') + ->get() + ->filter(fn (OAuthGrant $grant) => $grant->client !== null) + ->map(fn (OAuthGrant $grant) => [ + 'id' => $grant->uuid, + 'app' => [ + 'name' => $grant->client->name, + 'logo_url' => $grant->client->logo_url, + 'website_url' => $grant->client->website_url, + 'official' => $grant->client->official, + 'disabled' => $grant->client->isDisabled(), + ], + 'organization' => $grant->company ? ['id' => $grant->company->public_id, 'name' => $grant->company->name] : null, + 'scopes' => OAuthScopes::describe($grant->scopes ?? [], $grant->company?->name), + 'created_at' => $grant->created_at, + 'last_used_at' => $grant->last_used_at, + ]) + ->values(); + + return new JsonResponse(['connected_apps' => $grants]); + } + + /** + * DELETE int/v1/oauth-server/connected-apps/{uuid}. + */ + public function destroy(Request $request, string $uuid): JsonResponse + { + $grant = OAuthGrant::where('uuid', $uuid)->where('user_uuid', $request->user()->uuid)->firstOrFail(); + $grant->revoke(); + + return new JsonResponse(['status' => 'ok']); + } +} diff --git a/src/Http/Controllers/Internal/v1/OAuthAppController.php b/src/Http/Controllers/Internal/v1/OAuthAppController.php new file mode 100644 index 00000000..8f3684f6 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthAppController.php @@ -0,0 +1,203 @@ + fn ($query) => $query->whereNull('revoked_at')]) + ->orderBy('name') + ->get(); + + return new JsonResponse(['apps' => $apps, 'scopes' => $this->scopes()]); + } + + /** + * GET int/v1/oauth-server/apps/{id}. + */ + public function show(AdminRequest $request, string $id): JsonResponse + { + return new JsonResponse(['app' => OAuthClient::findOrFail($id), 'scopes' => $this->scopes()]); + } + + /** + * POST int/v1/oauth-server/apps. + */ + public function store(AdminRequest $request): JsonResponse + { + $attributes = $this->validated($request->all()); + $client = new OAuthClient($attributes + ['created_by_uuid' => $request->user()->uuid]); + $secret = ($attributes['confidential'] ?? true) ? $client->issueSecret() : null; + $client->save(); + + return new JsonResponse(['app' => $client->fresh(), 'client_secret' => $secret], 201); + } + + /** + * PUT int/v1/oauth-server/apps/{id}. + */ + public function update(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $attributes = $this->validated($request->all(), $client); + unset($attributes['confidential']); + $client->fill($attributes)->save(); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/rotate-secret. The old secret stops working at once. + */ + public function rotateSecret(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + if (!$client->isConfidential()) { + return new JsonResponse(['errors' => ['A public app has no secret to rotate.']], 422); + } + + $secret = $client->issueSecret(); + $client->save(); + + return new JsonResponse(['app' => $client->fresh(), 'client_secret' => $secret]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/disable. Turns the app off and revokes every grant. + */ + public function disable(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $client->forceFill(['disabled_at' => now()])->save(); + + $grants = OAuthGrant::active()->where('client_id', $client->id)->pluck('uuid')->all(); + OAuthGrant::whereIn('uuid', $grants)->update(['revoked_at' => now()]); + OAuthGrant::revokeTokensFor($grants); + OAuthGrant::expireMintedKeysFor($grants); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/enable. Users have to authorize it again. + */ + public function enable(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $client->forceFill(['disabled_at' => null])->save(); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * GET int/v1/oauth-server/apps/{id}/grants. + */ + public function grants(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $grants = OAuthGrant::active() + ->where('client_id', $client->id) + ->with(['user:uuid,public_id,name,email', 'company:uuid,public_id,name']) + ->latest('updated_at') + ->limit(500) + ->get() + ->map(fn (OAuthGrant $grant) => [ + 'id' => $grant->uuid, + 'user' => $grant->user ? ['id' => $grant->user->public_id, 'name' => $grant->user->name, 'email' => $grant->user->email] : null, + 'organization' => $grant->company ? ['id' => $grant->company->public_id, 'name' => $grant->company->name] : null, + 'scopes' => $grant->scopes, + 'created_at' => $grant->created_at, + 'last_used_at' => $grant->last_used_at, + ]); + + return new JsonResponse(['grants' => $grants]); + } + + /** + * DELETE int/v1/oauth-server/grants/{uuid}. + */ + public function revokeGrant(AdminRequest $request, string $uuid): JsonResponse + { + OAuthGrant::findOrFail($uuid)->revoke(); + + return new JsonResponse(['status' => 'ok']); + } + + /** + * @return array + */ + protected function scopes(): array + { + return OAuthScopes::describe(array_keys(OAuthScopes::all())); + } + + /** + * @param array $input + * + * @return array + * + * @throws ValidationException + */ + protected function validated(array $input, ?OAuthClient $client = null): array + { + $required = $client ? 'sometimes' : 'required'; + $validator = Validator::make($input, [ + 'name' => [$required, 'string', 'max:120'], + 'description' => ['nullable', 'string', 'max:500'], + 'redirect_uris' => [$required, 'array', 'min:1', 'max:20'], + 'redirect_uris.*' => ['string', 'max:2000'], + 'allowed_scopes' => [$required, 'array', 'min:1'], + 'allowed_scopes.*' => ['string'], + 'logo_url' => ['nullable', 'url', 'max:2000'], + 'website_url' => ['nullable', 'url', 'max:2000'], + 'privacy_url' => ['nullable', 'url', 'max:2000'], + 'terms_url' => ['nullable', 'url', 'max:2000'], + 'official' => ['sometimes', 'boolean'], + 'development' => ['sometimes', 'boolean'], + 'confidential' => ['sometimes', 'boolean'], + ]); + + $validator->after(function ($validator) use ($input, $client) { + $development = (bool) ($input['development'] ?? $client?->development ?? false); + foreach ((array) ($input['redirect_uris'] ?? []) as $uri) { + if (!is_string($uri) || !OAuthClient::isAcceptableRedirectUri($uri, $development)) { + $validator->errors()->add('redirect_uris', "`{$uri}` must be an https URL without a fragment (http is allowed only for 127.0.0.1 or [::1] on a development app)."); + } + } + foreach ((array) ($input['allowed_scopes'] ?? []) as $scope) { + if (!is_string($scope) || !OAuthScopes::has($scope)) { + $validator->errors()->add('allowed_scopes', "`{$scope}` is not a registered scope."); + } + } + }); + + $attributes = $validator->validate(); + if (isset($attributes['redirect_uris'])) { + $attributes['redirect_uris'] = array_values(array_unique($attributes['redirect_uris'])); + } + if (isset($attributes['allowed_scopes'])) { + $attributes['allowed_scopes'] = array_values(array_unique($attributes['allowed_scopes'])); + } + + return $attributes; + } +} diff --git a/src/Http/Controllers/Internal/v1/OAuthConsentController.php b/src/Http/Controllers/Internal/v1/OAuthConsentController.php new file mode 100644 index 00000000..f7e89834 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthConsentController.php @@ -0,0 +1,173 @@ +user(); + + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->query())); + } catch (AuthorizeError $e) { + return $this->error($e); + } + + if ($this->server->needsReauthentication($authorization, $this->authTime($request))) { + return new JsonResponse(['reauthenticate' => true]); + } + + $client = $authorization->client; + $organizations = $authorization->requiresOrganization() ? OAuthOrganizations::forPicker($user) : collect(); + $previous = OAuthGrant::active()->where('user_uuid', $user->uuid)->where('client_id', $client->id)->whereNotNull('company_uuid')->latest('updated_at')->first(); + $selected = $organizations->firstWhere('uuid', $previous?->company_uuid) ?? $organizations->firstWhere('uuid', $user->company_uuid); + + return new JsonResponse([ + 'client' => [ + 'id' => $client->id, + 'name' => $client->name, + 'description' => $client->description, + 'logo_url' => $client->logo_url, + 'website_url' => $client->website_url, + 'privacy_url' => $client->privacy_url, + 'terms_url' => $client->terms_url, + 'official' => $client->official, + 'redirect_host' => parse_url($authorization->redirectUri, PHP_URL_HOST), + ], + 'user' => [ + 'name' => $user->name, + 'email' => $user->email, + 'avatar_url' => $user->avatar_url, + ], + 'scopes' => $this->scopeTemplates($authorization), + 'requires_organization' => $authorization->requiresOrganization(), + 'organizations' => $organizations->map(fn ($organization) => array_diff_key($organization, ['uuid' => true]))->values(), + 'selected_organization' => $selected && $selected['can_share'] ? $selected['id'] : null, + 'skip_consent' => $this->server->canSkipConsent($authorization, $user), + ]); + } + + /** + * POST int/v1/oauth-server/authorization {...authorization request, organization}. + */ + public function approve(Request $request): JsonResponse + { + $user = $request->user(); + + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->except(['organization']))); + if ($this->server->needsReauthentication($authorization, $this->authTime($request))) { + return new JsonResponse(['reauthenticate' => true], 409); + } + + $company = $authorization->requiresOrganization() ? OAuthOrganizations::find($user, $request->input('organization')) : null; + $result = $this->server->approve($authorization, $user, $company, $this->authTime($request)); + } catch (AuthorizeError $e) { + return $this->error($e, 422); + } + + if ($result['created']) { + $this->notify($user, $authorization, $company?->name); + } + + return new JsonResponse(['redirect' => $result['redirect']]); + } + + /** + * DELETE int/v1/oauth-server/authorization {...authorization request}. + */ + public function deny(Request $request): JsonResponse + { + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->all())); + } catch (AuthorizeError $e) { + return $this->error($e); + } + + return new JsonResponse(['redirect' => $this->server->deny($authorization)]); + } + + /** + * Only the authorization request's own parameters, whichever way the console sent them. + * + * @param array $input + * + * @return array + */ + protected function authorizationParams(array $input): array + { + return array_intersect_key($input, array_flip([ + 'response_type', 'client_id', 'redirect_uri', 'scope', 'state', 'nonce', + 'code_challenge', 'code_challenge_method', 'prompt', 'max_age', 'login_hint', 'ui_locales', + ])); + } + + /** + * When the console session was signed in: its Sanctum token was created after the password + * and two-factor checks passed. + */ + protected function authTime(Request $request): ?\DateTimeInterface + { + $token = method_exists($request->user(), 'currentAccessToken') ? $request->user()->currentAccessToken() : null; + + return $token?->created_at ?? null; + } + + /** + * Consent lines with `{organization}` left in, so the console can fill in the one picked. + * + * @return array + */ + protected function scopeTemplates(ValidatedAuthorization $authorization): array + { + return array_map(fn ($scope) => ['scope' => $scope] + array_intersect_key(OAuthScopes::get($scope), ['description' => true, 'organization' => true]), $authorization->scopes); + } + + protected function error(AuthorizeError $e, int $status = 400): JsonResponse + { + return new JsonResponse([ + 'error' => $e->error, + 'error_description' => $e->description, + 'redirect' => $e->redirectUrl($this->server->issuer()), + ], $status); + } + + protected function notify(User $user, ValidatedAuthorization $authorization, ?string $organizationName): void + { + try { + $descriptions = array_column(OAuthScopes::describe($authorization->scopes, $organizationName), 'description'); + $user->notify(new OAuthAppAuthorized($authorization->client->name, $organizationName, $descriptions)); + } catch (\Throwable $e) { + Log::warning('[Fleetbase] Could not send the app authorized notice.', ['error' => $e->getMessage()]); + } + } +} diff --git a/src/Http/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index baa6b4b7..9817e197 100644 --- a/src/Http/Controllers/Internal/v1/OnboardController.php +++ b/src/Http/Controllers/Internal/v1/OnboardController.php @@ -5,9 +5,11 @@ use Fleetbase\Events\AccountCreated; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\OnboardRequest; +use Fleetbase\Jobs\RegisterHostedRouting; use Fleetbase\Models\Company; use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; +use Fleetbase\Support\HostedRouting; use Fleetbase\Support\OAuth; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -24,7 +26,9 @@ public function shouldOnboard() { return response()->json( [ - 'should_onboard' => Company::doesntExist(), + 'should_onboard' => $shouldOnboard = Company::doesntExist(), + // Offer the first admin a free Fleetbase Hosted Routing key. + 'hosted_routing' => $shouldOnboard && HostedRouting::isAvailable(), ] ); } @@ -96,6 +100,12 @@ public function createAccount(OnboardRequest $request) // send account created event event(new AccountCreated($user, $company)); + // The first admin chose "Use Fleetbase hosted routing (free)": register + // for a key once the response is sent, so signup never waits on Cloud. + if ($isAdmin && $request->boolean('hosted_routing') && HostedRouting::isEnabled() && $user->email) { + RegisterHostedRouting::afterResponse($user->email, $request->getSchemeAndHttpHost()); + } + // create auth token $token = $user->createToken($user->uuid); diff --git a/src/Http/Controllers/Internal/v1/ReviewAccessController.php b/src/Http/Controllers/Internal/v1/ReviewAccessController.php new file mode 100644 index 00000000..17721dfb --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ReviewAccessController.php @@ -0,0 +1,104 @@ +subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'view_permission', $request)) { + return response()->authorizationError(); + } + + return response()->json(['review_access' => AppReview::status($subject)]); + } + + public function update(string $type, string $id, Request $request): JsonResponse + { + $subject = $this->subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'permission', $request)) { + return response()->authorizationError(); + } + + $request->validate([ + 'enabled' => 'sometimes|boolean', + 'regenerate_code' => 'sometimes|boolean', + ]); + + $enabled = $request->has('enabled') ? $request->boolean('enabled') : null; + + [, $code] = AppReview::update($subject, $enabled, $request->boolean('regenerate_code'), $request->session()->get('user')); + + $status = AppReview::status($subject); + if ($code !== null) { + $status['code'] = $code; + } + + return response()->json(['review_access' => $status]); + } + + protected function subject(string $type, string $id, Request $request): ?Model + { + return AppReview::resolveSubject($type, $id, $request->session()->get('company')); + } + + /** + * Administrators always may; anyone else needs the permission the type registered, when + * it registered one. + */ + protected function allowed(string $type, string $key, Request $request): bool + { + $permission = AppReview::definition($type)[$key] ?? null; + if (!$permission) { + return true; + } + + $user = Auth::getUserFromSession($request); + if (!$user) { + return false; + } + + if (!$user->isNotAdmin()) { + return true; + } + + // The permission or a wildcard over it, the way AuthorizationGuard reads them. + $names = [$permission]; + $parts = explode(' ', $permission); + if (count($parts) === 3) { + $names[] = implode(' ', [$parts[0], '*', $parts[2]]); + $names[] = implode(' ', [$parts[0], '*']); + } + + $permissions = Permission::findByNames($names); + + return $permissions->isNotEmpty() && !$user->doesntHavePermissions($permissions); + } +} diff --git a/src/Http/Controllers/Internal/v1/WebhookEndpointController.php b/src/Http/Controllers/Internal/v1/WebhookEndpointController.php index 869eb880..3e7cb78b 100644 --- a/src/Http/Controllers/Internal/v1/WebhookEndpointController.php +++ b/src/Http/Controllers/Internal/v1/WebhookEndpointController.php @@ -105,6 +105,32 @@ public static function events() return response()->json(config('api.events')); } + /** + * Show an endpoint's signing secret to a console user who can manage webhooks. + */ + public function revealSecret(string $id) + { + $webhook = WebhookEndpoint::where('uuid', $id)->where('company_uuid', session('company'))->first(); + if (!$webhook) { + return response()->error('No webhook found', 404); + } + + return response()->json(['secret' => $webhook->signingSecret()]); + } + + /** + * Replace an endpoint's signing secret; the new value is returned once. + */ + public function rotateSecret(string $id) + { + $webhook = WebhookEndpoint::where('uuid', $id)->where('company_uuid', session('company'))->first(); + if (!$webhook) { + return response()->error('No webhook found', 404); + } + + return response()->json(['secret' => $webhook->rotateSecret()]); + } + /** * Get all webhook versions applicable. * diff --git a/src/Http/Controllers/OAuthServer/AuthorizationController.php b/src/Http/Controllers/OAuthServer/AuthorizationController.php new file mode 100644 index 00000000..597e2c16 --- /dev/null +++ b/src/Http/Controllers/OAuthServer/AuthorizationController.php @@ -0,0 +1,38 @@ +validateAuthorizationRequest($request->query()); + } catch (AuthorizeError $e) { + if ($e->canRedirect()) { + return new RedirectResponse($e->redirectUrl($server->issuer())); + } + + return new RedirectResponse(Utils::consoleUrl($consentPath, ['error' => $e->error, 'error_description' => $e->description])); + } + + return new RedirectResponse(Utils::consoleUrl($consentPath, $request->query())); + } +} diff --git a/src/Http/Controllers/OAuthServer/DiscoveryController.php b/src/Http/Controllers/OAuthServer/DiscoveryController.php new file mode 100644 index 00000000..b5f8d7cd --- /dev/null +++ b/src/Http/Controllers/OAuthServer/DiscoveryController.php @@ -0,0 +1,23 @@ +discovery(), 200, ['Cache-Control' => 'public, max-age=3600']); + } + + public function jwks(OAuthServer $server): JsonResponse + { + return new JsonResponse(['keys' => [$server->keys()->jwk()]], 200, ['Cache-Control' => 'public, max-age=3600']); + } +} diff --git a/src/Http/Controllers/OAuthServer/RegistrationController.php b/src/Http/Controllers/OAuthServer/RegistrationController.php new file mode 100644 index 00000000..d30bbdb0 --- /dev/null +++ b/src/Http/Controllers/OAuthServer/RegistrationController.php @@ -0,0 +1,105 @@ +input('software_id'); + $software = is_string($softwareId) ? config('oauth-server.registration.software.' . $softwareId) : null; + if (!is_array($software) || empty($software['name'])) { + return static::error('invalid_client_metadata', 'Unknown `software_id`. Only software Fleetbase lists can register.'); + } + + $uris = $request->input('redirect_uris'); + if (!is_array($uris) || count($uris) !== 1 || !is_string($uris[0] ?? null)) { + return static::error('invalid_redirect_uri', 'Register exactly one redirect URI.'); + } + + $uri = $uris[0]; + $loopback = OAuthClient::isLoopbackUri($uri); + if (!OAuthClient::isAcceptableRedirectUri($uri, $loopback)) { + return static::error('invalid_redirect_uri', 'The redirect URI must use https, or http on a loopback address, and have no fragment.'); + } + + $key = OAuthClient::registrationKey($softwareId, $uri); + $client = OAuthClient::where('registration_key', $key)->first(); + if ($client?->isDisabled()) { + return static::error('invalid_client_metadata', 'This site\'s registration was turned off. Contact Fleetbase support.'); + } + + $created = false; + if (!$client) { + $scopes = array_values(array_filter((array) ($software['scopes'] ?? []), fn ($scope) => is_string($scope) && OAuthScopes::has($scope))); + $host = (string) parse_url($uri, PHP_URL_HOST); + + $client = new OAuthClient([ + 'name' => Str::limit($software['name'] . ' (' . $host . ')', 250, ''), + 'description' => $software['description'] ?? null, + 'redirect_uris' => [$uri], + 'allowed_scopes' => $scopes, + 'logo_url' => $software['logo_url'] ?? null, + 'website_url' => $software['website_url'] ?? null, + 'privacy_url' => $software['privacy_url'] ?? null, + 'terms_url' => $software['terms_url'] ?? null, + 'official' => false, + 'development' => $loopback, + 'software_id' => $softwareId, + 'registration_key' => $key, + ]); + $client->save(); + $created = true; + } + + return new JsonResponse(static::metadata($client), $created ? 201 : 200, ['Cache-Control' => 'no-store']); + } + + /** + * The client information response (RFC 7591 §3.2.1). + * + * @return array + */ + public static function metadata(OAuthClient $client): array + { + return [ + 'client_id' => $client->id, + 'client_id_issued_at' => $client->created_at?->getTimestamp(), + 'client_name' => $client->name, + 'software_id' => $client->software_id, + 'redirect_uris' => array_values($client->redirect_uris ?? []), + 'grant_types' => ClientRepository::GRANT_TYPES, + 'response_types' => ['code'], + 'token_endpoint_auth_method' => 'none', + 'scope' => implode(' ', $client->allowed_scopes ?? []), + ]; + } + + protected static function error(string $code, string $description, int $status = 400): JsonResponse + { + return new JsonResponse(['error' => $code, 'error_description' => $description], $status, ['Cache-Control' => 'no-store']); + } +} diff --git a/src/Http/Controllers/OAuthServer/ResourceController.php b/src/Http/Controllers/OAuthServer/ResourceController.php new file mode 100644 index 00000000..aafb404e --- /dev/null +++ b/src/Http/Controllers/OAuthServer/ResourceController.php @@ -0,0 +1,217 @@ +user, $token->scopes ?? [], $token->company), 200, ['Cache-Control' => 'no-store']); + } + + /** + * GET /oauth/v1/organization (scope: organization:read). + */ + public function organization(Request $request): JsonResponse + { + $company = AuthenticateOAuthToken::company($request); + + return new JsonResponse(['organization' => static::organizationPayload($company)], 200, ['Cache-Control' => 'no-store']); + } + + /** + * POST /oauth/v1/api-key (scope: api-key:create). + * + * The live Fleetbase API key (`flb_live_…`) for this app in the token's organization. Creates + * one, named after the app, or returns the one this app created before; a key someone else + * created is never returned, whatever its name. It is listed under Developers → API Keys, and + * revoking the app's last grant in the organization expires it. + * + * An app that serves many installations from one client (one Shopify app, many shops) passes + * `installation`, e.g. "store.myshopify.com": each installation gets its own key, named + * "{app} ({installation})". Without it the app has one key per organization. + */ + public function apiKey(Request $request): JsonResponse + { + $token = AuthenticateOAuthToken::token($request); + $company = AuthenticateOAuthToken::company($request); + $client = $token->client; + $installation = static::installation($request); + if ($installation === false) { + return static::invalidInstallation(); + } + + // ApiCredential decides test mode from this console header; a key minted here is always live. + $request->headers->remove('Access-Console-Sandbox'); + + $credential = static::mintedKeys($company->uuid, $client->id, $installation)->where('test_mode', false)->first(); + + $created = false; + if (!$credential) { + $credential = ApiCredential::create([ + 'company_uuid' => $company->uuid, + 'user_uuid' => $token->user_uuid, + 'name' => $installation === null ? $client->name : Str::limit($client->name . ' (' . $installation . ')', 250, ''), + 'oauth_client_id' => $client->id, + 'oauth_installation' => $installation, + 'test_mode' => false, + ]); + $created = true; + } + + $credential = $credential->fresh() ?? $credential; + + return new JsonResponse([ + 'api_key' => [ + 'id' => $credential->public_id ?? $credential->uuid, + 'name' => $credential->name, + 'key' => $credential->key, + 'installation' => $installation, + 'created' => $created, + 'created_at' => $credential->created_at, + ], + ], $created ? 201 : 200, ['Cache-Control' => 'no-store']); + } + + /** + * DELETE /oauth/v1/api-key (scope: api-key:create). + * + * Expires the key this app minted in the token's organization for one installation (or the + * app-wide key when `installation` is omitted), without touching the grant or other + * installations' keys. Idempotent: 204 whether or not there was a key. + */ + public function revokeApiKey(Request $request): JsonResponse|Response + { + $token = AuthenticateOAuthToken::token($request); + $company = AuthenticateOAuthToken::company($request); + $installation = static::installation($request); + if ($installation === false) { + return static::invalidInstallation(); + } + + static::mintedKeys($company->uuid, $token->client->id, $installation) + ->get() + ->each(fn (ApiCredential $credential) => $credential->forceFill(['expires_at' => now()])->save()); + + return new Response('', 204, ['Cache-Control' => 'no-store']); + } + + /** + * The unexpired keys an app minted in an organization for one installation (null = app-wide). + */ + protected static function mintedKeys(string $companyUuid, string $clientId, ?string $installation) + { + return ApiCredential::where('company_uuid', $companyUuid) + ->where('oauth_client_id', $clientId) + ->when( + $installation === null, + fn ($query) => $query->whereNull('oauth_installation'), + fn ($query) => $query->where('oauth_installation', $installation) + ) + ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } + + /** + * The optional `installation`: null when absent or blank, false when it isn't a usable string. + */ + protected static function installation(Request $request): string|false|null + { + $installation = $request->input('installation'); + if ($installation === null) { + return null; + } + if (!is_string($installation) || mb_strlen(trim($installation)) > 190 || preg_match('/[\x00-\x1F\x7F]/', $installation)) { + return false; + } + + $installation = trim($installation); + + return $installation === '' ? null : $installation; + } + + protected static function invalidInstallation(): JsonResponse + { + return new JsonResponse(['error' => 'invalid_request', 'error_description' => '`installation` must be a string of at most 190 characters.'], 400, ['Cache-Control' => 'no-store']); + } + + /** + * GET|PUT|DELETE /oauth/v1/review-access/{type}/{id}. + * + * Turns App Review sign-in on or off for something the organization owns (a store, a network, + * the Navigator app). The scope that covers the type is checked here, because extensions + * register which scope covers which of their types. + * + * - GET: the status; never the code. + * - PUT: turn on. The 6-digit code is in the response only when this call created it (the + * first time, or with `regenerate_code`); it can never be read back. + * - DELETE: turn off. The code is kept, so turning on again restores it. + */ + public function reviewAccess(Request $request, string $type, string $id): JsonResponse + { + $scope = OAuthScopes::scopeForReviewAccessType($type); + $token = AuthenticateOAuthToken::token($request); + if (!$scope || AppReview::definition($type) === null) { + return new JsonResponse(['error' => 'not_found', 'error_description' => "Unknown review access type `{$type}`."], 404); + } + if (!$token->hasScope($scope) || !$token->client->allowsScope($scope)) { + return (new \Fleetbase\Auth\OAuthServer\Exceptions\TokenError('insufficient_scope', "This request needs the `{$scope}` scope.", 403, $scope))->toResponse(); + } + + $subject = AppReview::resolveSubject($type, $id, $token->company_uuid); + if (!$subject) { + return new JsonResponse(['error' => 'not_found', 'error_description' => 'Nothing with that id in this organization.'], 404); + } + + if ($request->isMethod('GET')) { + return new JsonResponse(['review_access' => AppReview::status($subject)], 200, ['Cache-Control' => 'no-store']); + } + + $enabled = !$request->isMethod('DELETE'); + [, $plainCode] = AppReview::update($subject, $enabled, $enabled && $request->boolean('regenerate_code'), $token->user_uuid); + $status = AppReview::status($subject); + if ($plainCode !== null) { + $status['code'] = $plainCode; + } + + return new JsonResponse(['review_access' => $status], 200, ['Cache-Control' => 'no-store']); + } + + /** + * @return array + */ + public static function organizationPayload(Company $company): array + { + return [ + 'id' => $company->public_id, + 'name' => $company->name, + 'logo_url' => $company->logo_url, + 'currency' => $company->currency, + 'country' => $company->country, + 'timezone' => $company->timezone, + 'website_url' => $company->website_url, + ]; + } +} diff --git a/src/Http/Controllers/OAuthServer/TokenController.php b/src/Http/Controllers/OAuthServer/TokenController.php new file mode 100644 index 00000000..cfa1d49f --- /dev/null +++ b/src/Http/Controllers/OAuthServer/TokenController.php @@ -0,0 +1,23 @@ +respondToTokenRequest($request); + } + + public function revoke(Request $request, OAuthServer $server) + { + return $server->revoke($request); + } +} diff --git a/src/Http/Middleware/AuthenticateOAuthToken.php b/src/Http/Middleware/AuthenticateOAuthToken.php new file mode 100644 index 00000000..d43b0430 --- /dev/null +++ b/src/Http/Middleware/AuthenticateOAuthToken.php @@ -0,0 +1,65 @@ +middleware(AuthenticateOAuthToken::class . ':storefront:read') + * + * Every listed scope is required. The token, its user and its organization are put on the + * request; controllers read them with AuthenticateOAuthToken::token() / ::company(). Nothing is + * put in the session: these requests never act as the user's console session. + */ +class AuthenticateOAuthToken +{ + public const ATTRIBUTE = 'oauth_access_token'; + + public function __construct(protected OAuthServer $server) + { + } + + public function handle(Request $request, \Closure $next, string ...$scopes) + { + try { + $token = $this->server->authenticate($request, $scopes); + } catch (TokenError $e) { + return $e->toResponse(); + } + + $request->attributes->set(static::ATTRIBUTE, $token); + $request->setUserResolver(fn () => $token->user); + + return $next($request); + } + + public static function token(Request $request): OAuthAccessToken + { + $token = $request->attributes->get(static::ATTRIBUTE); + if (!$token instanceof OAuthAccessToken) { + throw new \LogicException('This route is missing the AuthenticateOAuthToken middleware.'); + } + + return $token; + } + + /** + * The organization the token is bound to. Only call it on routes that require an + * organization scope, which guarantees there is one. + */ + public static function company(Request $request): Company + { + $company = static::token($request)->company; + if (!$company) { + throw new \LogicException('This token is not bound to an organization; require an organization scope on the route.'); + } + + return $company; + } +} diff --git a/src/Http/Requests/Api/WebhookEndpointRequest.php b/src/Http/Requests/Api/WebhookEndpointRequest.php new file mode 100644 index 00000000..8a16e9d8 --- /dev/null +++ b/src/Http/Requests/Api/WebhookEndpointRequest.php @@ -0,0 +1,47 @@ + + */ + public function rules(): array + { + $events = config('api.events'); + $known = is_array($events) && count($events) ? 'in:' . implode(',', $events) : 'string'; + + return [ + 'url' => [$this->isMethod('post') ? 'required' : 'sometimes', 'string', 'url', 'max:2048', new PublicWebhookUrl()], + 'events' => ['sometimes', 'nullable', 'array'], + 'events.*' => ['string', $known], + 'description' => ['sometimes', 'nullable', 'string', 'max:500'], + 'version' => ['sometimes', 'nullable', 'string', 'max:32'], + 'status' => ['sometimes', 'in:enabled,disabled'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'url.required' => 'A webhook URL is required.', + 'url.url' => 'The webhook URL must be a valid URL.', + 'events.*.in' => 'Unknown webhook event: :input.', + ]; + } +} diff --git a/src/Http/Requests/OnboardRequest.php b/src/Http/Requests/OnboardRequest.php index 7996fe0b..ea67bb99 100644 --- a/src/Http/Requests/OnboardRequest.php +++ b/src/Http/Requests/OnboardRequest.php @@ -45,6 +45,7 @@ public function rules() // 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'], + 'hosted_routing' => ['nullable', 'boolean'], '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/Jobs/RegisterHostedRouting.php b/src/Jobs/RegisterHostedRouting.php new file mode 100644 index 00000000..bb38a38c --- /dev/null +++ b/src/Jobs/RegisterHostedRouting.php @@ -0,0 +1,33 @@ +dispatchAfterResponse(new static($email, $domain)); + } + + public function handle(): void + { + HostedRouting::register($this->email, $this->domain); + } +} diff --git a/src/Listeners/SendResourceLifecycleWebhook.php b/src/Listeners/SendResourceLifecycleWebhook.php index 6de632bb..df8b0327 100644 --- a/src/Listeners/SendResourceLifecycleWebhook.php +++ b/src/Listeners/SendResourceLifecycleWebhook.php @@ -140,6 +140,7 @@ protected function sendWebhooksForEvent($event, array $context) ->url($webhook->url) ->payload($payload) ->useSecret($apiSecret) + ->useEndpointSecret($this->endpointSecret($webhook)) ->dispatch(); } catch (\Exception|\Aws\Sqs\Exception\SqsException $exception) { // get webhook attempt request/response interfaces @@ -301,4 +302,19 @@ public function getHumanReadableEventDescription(ResourceLifecycleEvent $event) // return description return $description; } + + /** + * The endpoint's own signing secret, or null when it cannot be loaded. A missing secret only + * drops the X-Fleetbase-Signature header; the delivery still goes out with the legacy signature. + */ + protected function endpointSecret(WebhookEndpoint $webhook): ?string + { + try { + return $webhook->signingSecret(); + } catch (\Throwable $exception) { + logger()->warning('Webhook endpoint signing secret unavailable: ' . $exception->getMessage(), ['webhook_uuid' => $webhook->uuid]); + + return null; + } + } } diff --git a/src/Models/ApiCredential.php b/src/Models/ApiCredential.php index 03e0b67e..4cb410f6 100644 --- a/src/Models/ApiCredential.php +++ b/src/Models/ApiCredential.php @@ -40,7 +40,7 @@ class ApiCredential extends Model * * @var array */ - protected $fillable = ['uuid', '_key', 'user_uuid', 'company_uuid', 'name', 'key', 'secret', 'test_mode', 'api', 'browser_origins', 'last_used_at', 'expires_at']; + protected $fillable = ['uuid', '_key', 'user_uuid', 'company_uuid', 'name', 'key', 'secret', 'test_mode', 'api', 'oauth_client_id', 'oauth_installation', 'browser_origins', 'last_used_at', 'expires_at']; /** * The attributes that should be cast to native types. diff --git a/src/Models/File.php b/src/Models/File.php index 1eaa37e3..19b32734 100644 --- a/src/Models/File.php +++ b/src/Models/File.php @@ -32,6 +32,21 @@ class File extends Model use LogsActivity; use SendsWebhooks; + /** + * Extensions an uploaded file is never stored under, because a web server may execute them. + */ + public const EXECUTABLE_UPLOAD_EXTENSIONS = [ + 'php', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', 'phtml', 'phar', 'pht', 'phps', 'shtml', 'htaccess', 'cgi', + ]; + + /** + * Data formats whose extension is kept even when content detection reports a looser type. + */ + public const DATA_UPLOAD_EXTENSIONS = [ + 'csv', 'tsv', 'txt', 'json', 'xls', 'xlsx', 'doc', 'docx', 'ppt', 'pptx', 'pdf', + 'zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'p12', 'pfx', 'pem', 'crt', 'cer', 'der', 'p7b', 'p7c', + ]; + /** * The type of public Id to generate. * @@ -608,7 +623,7 @@ public static function randomFileNameFromRequest($request, string $filename = 'f /** @var \Illuminate\Http\File|Symfony\Component\HttpFoundation\File\File|\Symfony\Component\HttpFoundation\File\UploadedFile $file */ if ($request->hasFile($filename)) { $file = $request->file($filename); - $extension = $extension ?? strtolower($file->getClientOriginalExtension()); + $extension = $extension ?? static::storageExtensionForUpload($file); $extension = Str::startsWith($extension, '.') ? $extension : '.' . $extension; $sqids = new \Sqids\Sqids(); @@ -618,6 +633,30 @@ public static function randomFileNameFromRequest($request, string $filename = 'f return static::randomFileName($extension); } + /** + * The extension to store an uploaded file under. + * + * The client's extension is kept when it matches the type detected from the file's + * contents, or when it is a plain data format that detection often reports loosely + * (a CSV as text/plain, an XLSX as application/zip). Otherwise the extension is taken + * from the detected type, falling back to "bin". Extensions a web server may execute + * are never used. + */ + public static function storageExtensionForUpload(UploadedFile $file): string + { + $clientExtension = strtolower($file->getClientOriginalExtension()); + $detectedType = $file->getMimeType(); + $typeExtensions = $detectedType ? \Symfony\Component\Mime\MimeTypes::getDefault()->getExtensions($detectedType) : []; + + if ($clientExtension !== '' && (in_array($clientExtension, $typeExtensions, true) || in_array($clientExtension, static::DATA_UPLOAD_EXTENSIONS, true))) { + $extension = $clientExtension; + } else { + $extension = strtolower($file->guessExtension() ?? 'bin'); + } + + return in_array($extension, static::EXECUTABLE_UPLOAD_EXTENSIONS, true) ? 'bin' : $extension; + } + /** * Retrieves a collection of files based on UUIDs provided via a request. * diff --git a/src/Models/OAuthAccessToken.php b/src/Models/OAuthAccessToken.php new file mode 100644 index 00000000..2b4f8636 --- /dev/null +++ b/src/Models/OAuthAccessToken.php @@ -0,0 +1,67 @@ + 'array', + 'revoked' => 'boolean', + 'expires_at' => 'datetime', + ]; + + public function grant(): BelongsTo + { + return $this->belongsTo(OAuthGrant::class, 'grant_uuid', 'uuid'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OAuthClient::class, 'client_id', 'id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes ?? [], true); + } + + public function isUsable(): bool + { + return !$this->revoked && $this->expires_at && $this->expires_at->isFuture(); + } +} diff --git a/src/Models/OAuthAuthCode.php b/src/Models/OAuthAuthCode.php new file mode 100644 index 00000000..afd63ed6 --- /dev/null +++ b/src/Models/OAuthAuthCode.php @@ -0,0 +1,37 @@ + 'array', + 'revoked' => 'boolean', + 'auth_time' => 'datetime', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; +} diff --git a/src/Models/OAuthClient.php b/src/Models/OAuthClient.php new file mode 100644 index 00000000..1f1ca651 --- /dev/null +++ b/src/Models/OAuthClient.php @@ -0,0 +1,264 @@ + + */ + protected $fillable = [ + 'name', + 'description', + 'redirect_uris', + 'allowed_scopes', + 'logo_url', + 'website_url', + 'privacy_url', + 'terms_url', + 'official', + 'development', + 'created_by_uuid', + 'software_id', + 'registration_key', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = ['secret']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'redirect_uris' => 'array', + 'allowed_scopes' => 'array', + 'official' => 'boolean', + 'development' => 'boolean', + 'secret_rotated_at' => 'datetime', + 'disabled_at' => 'datetime', + ]; + + /** + * Dynamic attributes appended to the model's JSON form. + * + * @var array + */ + protected $appends = ['confidential']; + + /** + * Generate the client id on create. + */ + protected static function booted(): void + { + static::creating(function (self $client) { + if (empty($client->id)) { + $client->id = (string) Str::uuid(); + } + }); + } + + /** + * The key that identifies a dynamically registered client: one per software and redirect URI. + */ + public static function registrationKey(string $softwareId, string $redirectUri): string + { + return hash('sha256', $softwareId . '|' . $redirectUri); + } + + /** + * Whether the client registered itself through POST /oauth/register. + */ + public function isRegistered(): bool + { + return !empty($this->software_id); + } + + /** + * The grants users have given this client. + */ + public function grants(): HasMany + { + return $this->hasMany(OAuthGrant::class, 'client_id', 'id'); + } + + /** + * Whether the client authenticates with a secret. + */ + public function isConfidential(): bool + { + return !empty($this->secret); + } + + /** + * Accessor for `confidential` in the JSON form. + */ + public function getConfidentialAttribute(): bool + { + return $this->isConfidential(); + } + + /** + * Whether an admin turned the client off. + */ + public function isDisabled(): bool + { + return $this->disabled_at !== null; + } + + /** + * Generate, hash and store a new secret, returning the plain value. + * + * The caller shows the plain value once; it can never be read back. + */ + public function issueSecret(): string + { + $plain = Str::random(64); + $this->secret = password_hash($plain, PASSWORD_BCRYPT); + $this->secret_rotated_at = now(); + + return $plain; + } + + /** + * Check a presented secret against the stored hash. + */ + public function verifySecret(?string $plain): bool + { + if (!$this->isConfidential() || !is_string($plain) || $plain === '') { + return false; + } + + return password_verify($plain, $this->secret); + } + + /** + * Whether the client was registered for this scope. + */ + public function allowsScope(string $scope): bool + { + return in_array($scope, $this->allowed_scopes ?? [], true); + } + + /** + * Whether a redirect URI is registered for this client. + * + * Matching is exact: scheme, host, port, path and query. The one exception is a development + * client using a loopback address, where native-app guidance (RFC 8252 §7.3) says the port + * must be allowed to vary. + */ + public function allowsRedirectUri(?string $uri): bool + { + if (!is_string($uri) || $uri === '') { + return false; + } + + $registered = $this->redirect_uris ?? []; + if (in_array($uri, $registered, true)) { + return true; + } + + if (!$this->development || !static::isLoopbackUri($uri)) { + return false; + } + + $withoutPort = static::withoutPort($uri); + foreach ($registered as $candidate) { + if (static::isLoopbackUri($candidate) && static::withoutPort($candidate) === $withoutPort) { + return true; + } + } + + return false; + } + + /** + * Whether a URI is an acceptable redirect URI to register. + * + * https everywhere; http only for loopback addresses; no fragments (RFC 6749 §3.1.2). + */ + public static function isAcceptableRedirectUri(string $uri, bool $development = false): bool + { + $parts = parse_url($uri); + if ($parts === false || empty($parts['scheme']) || empty($parts['host']) || isset($parts['fragment'])) { + return false; + } + + $scheme = strtolower($parts['scheme']); + if ($scheme === 'https') { + return true; + } + + return $scheme === 'http' && static::isLoopbackUri($uri) && $development; + } + + /** + * Whether a URI points at the loopback interface over http. + */ + public static function isLoopbackUri(string $uri): bool + { + $parts = parse_url($uri); + if ($parts === false || strtolower($parts['scheme'] ?? '') !== 'http') { + return false; + } + + return in_array(strtolower($parts['host'] ?? ''), ['127.0.0.1', '[::1]', 'localhost'], true); + } + + /** + * The URI with its port removed, for loopback comparison. + */ + protected static function withoutPort(string $uri): string + { + return preg_replace('#^(http://[^/:]+|http://\[::1\]):\d+#i', '$1', $uri); + } +} diff --git a/src/Models/OAuthGrant.php b/src/Models/OAuthGrant.php new file mode 100644 index 00000000..fddb0055 --- /dev/null +++ b/src/Models/OAuthGrant.php @@ -0,0 +1,210 @@ + + */ + protected $fillable = ['uuid', 'user_uuid', 'client_id', 'company_uuid', 'scopes', 'last_used_at', 'revoked_at']; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = ['id']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'scopes' => 'array', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + /** + * Generate the uuid on create. + */ + protected static function booted(): void + { + static::creating(function (self $grant) { + if (empty($grant->uuid)) { + $grant->uuid = (string) Str::uuid(); + } + }); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OAuthClient::class, 'client_id', 'id'); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * Grants that haven't been revoked. + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('revoked_at'); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } + + /** + * Revoke the grant and every code and token issued under it. + */ + public function revoke(): void + { + $connection = $this->getConnectionName(); + + DB::connection($connection)->transaction(function () use ($connection) { + if (!$this->isRevoked()) { + $this->revoked_at = now(); + $this->save(); + } + + static::revokeTokensFor([$this->uuid], $connection); + static::expireMintedKeysFor([$this->uuid], $connection); + }); + } + + /** + * Expire the API keys an app created through api-key:create once it has no grant left in that + * organization. + * + * A key belongs to the app and the organization rather than to one user, so it survives as + * long as any admin's grant for that pair does. Called after the grants have been revoked. + * + * @param array $grantUuids + */ + public static function expireMintedKeysFor(array $grantUuids, ?string $connection = null): void + { + if (empty($grantUuids)) { + return; + } + + $db = DB::connection($connection ?? 'mysql'); + if (!$db->getSchemaBuilder()->hasColumn('api_credentials', 'oauth_client_id')) { + return; + } + + $pairs = $db->table('oauth_grants')->whereIn('uuid', $grantUuids)->whereNotNull('company_uuid') + ->select(['client_id', 'company_uuid'])->distinct()->get(); + + foreach ($pairs as $pair) { + $stillGranted = $db->table('oauth_grants') + ->where('client_id', $pair->client_id) + ->where('company_uuid', $pair->company_uuid) + ->whereNull('revoked_at') + ->exists(); + if ($stillGranted) { + continue; + } + + $db->table('api_credentials') + ->where('oauth_client_id', $pair->client_id) + ->where('company_uuid', $pair->company_uuid) + ->whereNull('deleted_at') + ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->update(['expires_at' => now(), 'updated_at' => now()]); + } + } + + /** + * Revoke every code and token under the given grants. + * + * @param array $grantUuids + */ + public static function revokeTokensFor(array $grantUuids, ?string $connection = null): void + { + if (empty($grantUuids)) { + return; + } + + $db = DB::connection($connection ?? 'mysql'); + + $db->table('oauth_auth_codes')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true]); + $db->table('oauth_access_tokens')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true, 'updated_at' => now()]); + $db->table('oauth_refresh_tokens')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true]); + } + + /** + * Record that the grant was used, at most once a minute so busy clients don't write per call. + */ + public function touchLastUsed(): void + { + if ($this->last_used_at && $this->last_used_at->gt(now()->subMinute())) { + return; + } + + $this->forceFill(['last_used_at' => now()])->saveQuietly(); + } +} diff --git a/src/Models/OAuthRefreshToken.php b/src/Models/OAuthRefreshToken.php new file mode 100644 index 00000000..a013d74c --- /dev/null +++ b/src/Models/OAuthRefreshToken.php @@ -0,0 +1,35 @@ + 'boolean', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; +} diff --git a/src/Models/ReviewAccess.php b/src/Models/ReviewAccess.php new file mode 100644 index 00000000..14f280a4 --- /dev/null +++ b/src/Models/ReviewAccess.php @@ -0,0 +1,66 @@ + 'boolean', + 'code_generated_at' => 'datetime', + 'enabled_at' => 'datetime', + ]; + + /** + * Turned on and holding a code: the only state in which a reviewer can sign in. + */ + public function isUsable(): bool + { + return $this->enabled && $this->hasCode(); + } + + public function hasCode(): bool + { + return filled($this->code); + } + + /** + * Replace the code with the given one, stored hashed. + */ + public function setPlainCode(string $code): void + { + $this->code = Hash::make($code); + $this->code_generated_at = now(); + } + + public function checkCode(string $code): bool + { + return $this->hasCode() && $code !== '' && Hash::check($code, $this->code); + } +} diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index fed8e79d..057776e1 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Mail\VerificationMail; use Fleetbase\Services\SmsService; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsLedger; @@ -396,10 +397,15 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $destination = data_get($options, 'phone') ?: data_get($subject, 'phone'); $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); + // A number reserved for fiction (an App Review number) is never texted: the code is + // saved as usual, but nothing is reserved or sent. App reviewers sign in with the code + // their app's owner set up instead (Fleetbase\Support\AppReview). + $fictional = $destination && AppReview::isFictionalPhone((string) $destination); + // Ask the guard BEFORE the code row exists: a refused send must leave nothing // behind, and the budgets must be counted even when the provider later fails. $reservation = null; - if ($destination) { + if ($destination && !$fictional) { $reservation = SmsGuard::make()->reserve(SmsContext::resolve((string) $destination, [ 'purpose' => $for, 'company_uuid' => $companyUuid, @@ -476,7 +482,7 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $provider = data_get($options, 'provider'); // Send SMS using SmsService with automatic provider routing - if ($destination) { + if ($destination && !$fictional) { try { $smsService = new SmsService(); $smsService->send($destination, $message, $smsOptions, $provider); diff --git a/src/Models/WebhookEndpoint.php b/src/Models/WebhookEndpoint.php index 5ada4169..94402678 100644 --- a/src/Models/WebhookEndpoint.php +++ b/src/Models/WebhookEndpoint.php @@ -38,6 +38,11 @@ class WebhookEndpoint extends Model */ protected $fillable = ['_key', 'company_uuid', 'created_by_uuid', 'updated_by_uuid', 'api_credential_uuid', 'url', 'mode', 'version', 'description', 'events', 'status']; + /** + * Prefix of generated signing secrets, so a leaked value is recognisable. + */ + public const SECRET_PREFIX = 'whsec_'; + /** * The attributes that should be casted to native types. * @@ -45,6 +50,7 @@ class WebhookEndpoint extends Model */ protected $casts = [ 'events' => 'array', + 'secret' => 'encrypted', ]; /** @@ -59,14 +65,15 @@ class WebhookEndpoint extends Model * * @var array */ - protected $hidden = ['apiCredential']; + protected $hidden = ['apiCredential', 'secret']; /** * Get the activity log options for the model. */ public function getActivitylogOptions(): LogOptions { - return LogOptions::defaults()->logOnly(['*'])->logOnlyDirty(); + // Never write the signing secret (decrypted by its cast) into the activity log + return LogOptions::defaults()->logOnly(['*'])->logExcept(['secret'])->logOnlyDirty(); } /** @@ -140,6 +147,51 @@ public function canFireEvent(string $event): bool return !$this->cannotFireEvent($event); } + /** + * New endpoints get a signing secret when they are created. + */ + protected static function booted(): void + { + static::creating(function (WebhookEndpoint $endpoint) { + if (empty($endpoint->secret)) { + $endpoint->secret = static::generateSecret(); + } + }); + } + + /** + * A new random signing secret. + */ + public static function generateSecret(): string + { + return static::SECRET_PREFIX . bin2hex(random_bytes(24)); + } + + /** + * The endpoint's signing secret, created (and saved) on first use for endpoints that predate + * per-endpoint secrets. + */ + public function signingSecret(): string + { + if (empty($this->secret)) { + $this->secret = static::generateSecret(); + $this->saveQuietly(); + } + + return $this->secret; + } + + /** + * Replace the signing secret and return the new one. Receivers must be updated. + */ + public function rotateSecret(): string + { + $this->secret = static::generateSecret(); + $this->saveQuietly(); + + return $this->secret; + } + /** * Enables the webhook. */ diff --git a/src/Notifications/OAuthAppAuthorized.php b/src/Notifications/OAuthAppAuthorized.php new file mode 100644 index 00000000..76456adb --- /dev/null +++ b/src/Notifications/OAuthAppAuthorized.php @@ -0,0 +1,81 @@ + $scopeDescriptions + */ + public function __construct( + public string $appName, + public ?string $organizationName, + public array $scopeDescriptions, + ) { + $this->authorizedAt = now()->toDayDateTimeString() . ' UTC'; + } + + /** + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $app = config('app.name'); + $subject = $this->organizationName + ? $this->appName . ' can now access ' . $this->organizationName + : $this->appName . ' can now sign you in with ' . $app; + + $message = (new MailMessage()) + ->subject($subject) + ->greeting('Hello, ' . ($notifiable->name ?? 'there')) + ->line($this->organizationName + ? 'You gave ' . $this->appName . ' access to ' . $this->organizationName . ' on ' . $app . '. It can:' + : 'You signed in to ' . $this->appName . ' with your ' . $app . ' account. It can:'); + + foreach ($this->scopeDescriptions as $description) { + $message->line('• ' . $description); + } + + return $message + ->line('Authorized: ' . $this->authorizedAt) + ->action('Review connected apps', Utils::consoleUrl('account/auth')) + ->line('If this was not you, remove the app from your connected apps and change your password straight away.'); + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return [ + 'app' => $this->appName, + 'organization' => $this->organizationName, + 'scopes' => $this->scopeDescriptions, + 'authorized_at' => $this->authorizedAt, + ]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 4abaaaa3..98bd8993 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -103,10 +103,14 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\DeleteUser::class, \Fleetbase\Console\Commands\BackupDatabase::class, \Fleetbase\Console\Commands\TelemetryPing::class, + \Fleetbase\Console\Commands\RegisterHostedRoutingCommand::class, \Fleetbase\Console\Commands\SmsGuardMode::class, \Fleetbase\Console\Commands\SmsGuardPause::class, \Fleetbase\Console\Commands\SmsGuardUnpause::class, \Fleetbase\Console\Commands\SmsLedgerPrune::class, + \Fleetbase\Console\Commands\OAuthServerKeys::class, + \Fleetbase\Console\Commands\OAuthServerClient::class, + \Fleetbase\Console\Commands\OAuthServerPrune::class, ]; /** @@ -153,6 +157,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/image.php', 'image'); $this->mergeConfigFrom(__DIR__ . '/../../config/sms.php', 'sms'); $this->mergeConfigFrom(__DIR__ . '/../../config/oauth.php', 'oauth'); + $this->mergeConfigFrom(__DIR__ . '/../../config/oauth-server.php', 'oauth-server'); // setup report schema registry $this->app->singleton(ReportSchemaRegistry::class, function () { @@ -211,6 +216,7 @@ public function boot() // are high-churn and short-lived; the model keeps a day's grace past expiry so a // support investigation can still tell an expired token from one that never existed. $schedule->command('model:prune', ['--model' => \Fleetbase\Models\OAuthState::class])->hourly(); + $schedule->command('oauth-server:prune')->daily(); $schedule->command('telemetry:ping')->daily(); $schedule->job(new \Fleetbase\Jobs\MaterializeSchedulesJob())->dailyAt('01:00')->name('materialize-schedules')->withoutOverlapping(); // Keep sandbox users/companies in sync with production so that @@ -230,6 +236,7 @@ public function boot() $this->registerTransformersFrom(); $this->registerMiddleware(); $this->registerNotifications(); + \Fleetbase\Auth\OAuthServer\OAuthScopes::registerDefaults(); $this->loadRoutesFrom(__DIR__ . '/../routes.php'); $this->loadMigrationsFrom(__DIR__ . '/../../migrations'); $this->loadViewsFrom(__DIR__ . '/../../views', 'fleetbase'); diff --git a/src/Services/FileResolverService.php b/src/Services/FileResolverService.php index 3382b304..0ca17c69 100644 --- a/src/Services/FileResolverService.php +++ b/src/Services/FileResolverService.php @@ -95,7 +95,7 @@ protected function resolveFromUpload(UploadedFile $upload, string $path, ?string $disk = $disk ?? config('filesystems.default'); // Generate a unique filename to prevent collisions - $extension = $upload->getClientOriginalExtension(); + $extension = File::storageExtensionForUpload($upload); $fileName = File::randomFileName($extension); // Ensure path doesn't have trailing slash diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index c9c3848c..7f5d9b30 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -2,6 +2,7 @@ namespace Fleetbase\Services; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsReservation; @@ -70,6 +71,14 @@ public function send(string $to, string $text, array $options = [], ?string $pro // Normalize phone number $normalizedPhone = $this->normalizePhoneNumber($to); + // A number reserved for fiction (the App Review numbers among them) can never reach + // anyone: answer as sent without calling a provider or counting it. + if (AppReview::isFictionalPhone($normalizedPhone)) { + Log::info('SMS to a fictional number skipped', ['to' => $to]); + + return ['success' => true, 'skipped' => true, 'provider' => null, 'message_id' => null]; + } + // Every send passes the guard before a provider is called. A caller that already // reserved the send (VerificationCode) hands its reservation over so it counts once. $reservation = $options['sms_reservation'] ?? null; diff --git a/src/Support/AppReview.php b/src/Support/AppReview.php new file mode 100644 index 00000000..237d833e --- /dev/null +++ b/src/Support/AppReview.php @@ -0,0 +1,381 @@ + Store::class, + * 'phone' => '+12025550143', + * 'permission' => 'storefront manage-app-review store', + * ]); + * + * Self-hosted installs can still allow review accounts instance-wide from env config; see + * configuredAccountMatches(). + */ +class AppReview +{ + /** + * Digits in a generated code. Fleetbase's apps take six-digit codes. + */ + public const CODE_LENGTH = 6; + + /** + * North American numbers with the 555 exchange and a line from 0100 to 0199 are reserved + * for fiction: they can never reach a real person. + */ + public const FICTIONAL_PHONE_PATTERN = '/^\+1[2-9]\d{2}55501\d{2}$/'; + + /** + * @var array, phone: string, name: string, permission: ?string, view_permission: ?string, resolve: ?callable}> + */ + protected static array $types = []; + + /** + * Register a type of subject an app signs reviewers in to. + * + * - `model`: the subject's model class (required) + * - `phone`: the review number, E.164 from the fictional range (required). Types whose + * accounts are the same kind of user may share one (a store and a network); types + * whose users differ (customers, drivers) need their own, since users are found by phone. + * - `name`: what to call the app in messages, e.g. "Storefront" + * - `permission`: the permission needed to change access; `view_permission` to read it + * - `resolve`: callable(string $id, ?string $companyUuid): ?Model finding the subject a + * Console user may manage; by default the model by uuid or public_id in the company + */ + public static function register(string $type, array $definition): void + { + $model = $definition['model'] ?? null; + $phone = static::normalizePhone($definition['phone'] ?? null); + + if (!is_string($model) || !class_exists($model)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a model class."); + } + + if (!static::isFictionalPhone($phone)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a review number from the fictional range +1 NXX 555 0100-0199."); + } + + static::$types[$type] = [ + 'type' => $type, + 'model' => $model, + 'phone' => $phone, + 'name' => $definition['name'] ?? $type, + 'permission' => $definition['permission'] ?? null, + 'view_permission' => $definition['view_permission'] ?? null, + 'resolve' => $definition['resolve'] ?? null, + ]; + } + + public static function forget(string $type): void + { + unset(static::$types[$type]); + } + + public static function types(): array + { + return static::$types; + } + + public static function definition(string $type): ?array + { + return static::$types[$type] ?? null; + } + + /** + * The registered type of a subject: the first type whose model it is. + */ + public static function typeOf(?Model $subject): ?string + { + if (!$subject) { + return null; + } + + foreach (static::$types as $type => $definition) { + if ($subject instanceof $definition['model']) { + return $type; + } + } + + return null; + } + + public static function phoneFor(string $type): ?string + { + return static::$types[$type]['phone'] ?? null; + } + + /** + * Whether a number is in the range reserved for fiction, however it is formatted. + */ + public static function isFictionalPhone(?string $phone): bool + { + $phone = static::normalizePhone($phone); + + return $phone !== null && preg_match(static::FICTIONAL_PHONE_PATTERN, $phone) === 1; + } + + /** + * Whether an identity is a registered review number: the given type's, or any type's. + */ + public static function isReviewPhone(?string $identity, ?string $type = null): bool + { + $phone = static::normalizePhone($identity); + if ($phone === null) { + return false; + } + + if ($type !== null) { + return static::phoneFor($type) === $phone; + } + + foreach (static::$types as $definition) { + if ($definition['phone'] === $phone) { + return true; + } + } + + return false; + } + + /** + * `+12025550143` as `+1 202 555 0143`. + */ + public static function formatPhone(?string $phone): ?string + { + $phone = static::normalizePhone($phone); + if ($phone === null || !preg_match('/^\+1(\d{3})(\d{3})(\d{4})$/', $phone, $parts)) { + return $phone; + } + + return "+1 {$parts[1]} {$parts[2]} {$parts[3]}"; + } + + /** + * The subject of a type a Console user in this company may manage, by uuid or public id. + */ + public static function resolveSubject(string $type, string $id, ?string $companyUuid): ?Model + { + $definition = static::definition($type); + if (!$definition) { + return null; + } + + if (is_callable($definition['resolve'])) { + return ($definition['resolve'])($id, $companyUuid); + } + + $model = $definition['model']; + $query = $model::query()->where(function ($query) use ($id, $model) { + $query->where('uuid', $id); + if (static::hasColumn($model, 'public_id')) { + $query->orWhere('public_id', $id); + } + }); + + if (is_a($model, Company::class, true)) { + return $companyUuid ? $query->where('uuid', $companyUuid)->first() : null; + } + + return $companyUuid ? $query->where('company_uuid', $companyUuid)->first() : null; + } + + /** + * The App Review access record of a subject, if it has one. + */ + public static function accessFor(?Model $subject): ?ReviewAccess + { + $type = static::typeOf($subject); + if (!$type || blank($subject->uuid)) { + return null; + } + + try { + return ReviewAccess::where('subject_type', $type)->where('subject_uuid', $subject->uuid)->first(); + } catch (\Throwable $e) { + // Before the migration has run there is no access, not an error. + return null; + } + } + + public static function isEnabledFor(?Model $subject): bool + { + return (bool) static::accessFor($subject)?->isUsable(); + } + + /** + * Whether a code signs the subject type's review number in to this subject: its access is + * on and the code is the one it generated. Another subject's code never matches. + */ + public static function acceptsCode(?Model $subject, ?string $identity, mixed $code): bool + { + $type = static::typeOf($subject); + if (!$type || !static::isReviewPhone($identity, $type) || blank($code) || !is_scalar($code)) { + return false; + } + + $access = static::accessFor($subject); + if (!$access || !$access->isUsable() || !$access->checkCode((string) $code)) { + return false; + } + + Log::info('[Fleetbase] App Review sign-in accepted.', [ + 'type' => $type, + 'subject' => $subject->public_id ?? $subject->uuid, + ]); + + return true; + } + + /** + * Change a subject's access. Turning it on without a code generates one, so it is never + * on with nothing to sign in with; `regenerate` replaces the code. Turning it off keeps + * the code, so turning it back on restores the same one. + * + * @return array{0: ReviewAccess, 1: ?string} the access and the new plain code, if one was generated + */ + public static function update(Model $subject, ?bool $enabled, bool $regenerate = false, ?string $updatedByUuid = null): array + { + $type = static::typeOf($subject); + if (!$type) { + throw new \InvalidArgumentException('No App Review type is registered for ' . get_class($subject) . '.'); + } + + $access = static::accessFor($subject) ?? new ReviewAccess([ + 'company_uuid' => $subject instanceof Company ? $subject->uuid : $subject->company_uuid, + 'subject_type' => $type, + 'subject_uuid' => $subject->uuid, + 'enabled' => false, + ]); + + if ($enabled !== null) { + if ($enabled && !$access->enabled) { + $access->enabled_at = now(); + } + $access->enabled = $enabled; + } + + $code = null; + if ($regenerate || ($access->enabled && !$access->hasCode())) { + $code = static::generateCode(); + $access->setPlainCode($code); + } + + $access->updated_by_uuid = $updatedByUuid; + $access->save(); + + return [$access, $code]; + } + + /** + * What the Console and the API show about a subject's access. Never the code or its hash; + * a caller adds `code` only to the response that generated it. + */ + public static function status(Model $subject): array + { + $type = static::typeOf($subject); + $access = static::accessFor($subject); + $phone = $type ? static::phoneFor($type) : null; + + return [ + 'type' => $type, + 'enabled' => (bool) $access?->enabled, + 'phone' => $phone, + 'phone_display' => static::formatPhone($phone), + 'has_code' => (bool) $access?->hasCode(), + 'code_generated_at' => $access?->code_generated_at?->toIso8601String(), + 'enabled_at' => $access?->enabled_at?->toIso8601String(), + ]; + } + + public static function generateCode(): string + { + return str_pad((string) random_int(0, (10 ** static::CODE_LENGTH) - 1), static::CODE_LENGTH, '0', STR_PAD_LEFT); + } + + /** + * Whether an identity is listed as an instance-wide review account in env config (for + * self-hosted installs) and a code is configured for those accounts. + * + * @param string $codeKey config key holding the code + * @param string $accountsKey config key holding the listed identities + */ + public static function isConfiguredAccount(string $codeKey, string $accountsKey, ?string $identity): bool + { + $configuredCode = config($codeKey); + + // `=== null || === ''` rather than blank(): a configured code of "0" is still a code. + if ($configuredCode === null || $configuredCode === '' || $identity === null || trim($identity) === '') { + return false; + } + + $accounts = array_map( + static fn ($account) => strtolower(trim((string) $account)), + (array) config($accountsKey, []) + ); + + return in_array(strtolower(trim($identity)), $accounts, true); + } + + /** + * Whether a code is the env-configured code for a listed review account. Only listed + * identities are accepted, so knowing the code is not enough to sign in as anyone else, + * and the comparison is constant-time. + */ + public static function configuredAccountMatches(string $codeKey, string $accountsKey, ?string $identity, mixed $code, string $channel = 'app'): bool + { + if ($code === null || $code === '' || !is_scalar($code) || !static::isConfiguredAccount($codeKey, $accountsKey, $identity)) { + return false; + } + + if (!hash_equals((string) config($codeKey), (string) $code)) { + return false; + } + + Log::warning('[Fleetbase] Verification bypass accepted for a review account.', [ + 'channel' => $channel, + 'identity' => $identity, + ]); + + return true; + } + + /** + * Digits with a leading `+`, or null for nothing. + */ + protected static function normalizePhone(?string $phone): ?string + { + if ($phone === null) { + return null; + } + + $digits = preg_replace('/\D+/', '', $phone); + + return $digits === '' ? null : '+' . $digits; + } + + protected static function hasColumn(string $model, string $column): bool + { + try { + $instance = new $model(); + + return $instance->getConnection()->getSchemaBuilder()->hasColumn($instance->getTable(), $column); + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/Support/HostedRouting.php b/src/Support/HostedRouting.php new file mode 100644 index 00000000..51cfc683 --- /dev/null +++ b/src/Support/HostedRouting.php @@ -0,0 +1,193 @@ +whereNotNull('email')->orderBy('created_at')->value('email'); + if (empty($email)) { + return static::result(static::STATUS_FAILED, 'no_email', 'No administrator email to register with. Pass --email.'); + } + + try { + $response = Http::timeout((int) config('fleetbase.hosted_routing.timeout', 10)) + ->acceptJson() + ->asJson() + ->post((string) config('fleetbase.hosted_routing.registration_url'), [ + 'product' => 'routing', + 'instance_id' => InstanceIdentity::id(), + 'instance_secret' => InstanceIdentity::secret(), + 'email' => $email, + 'domain' => $domain ?? config('app.url'), + 'version' => (string) config('fleetbase.version'), + ]); + } catch (\Throwable $e) { + Log::warning('Hosted routing registration failed: ' . $e->getMessage()); + + return static::result(static::STATUS_FAILED, 'unreachable', 'Fleetbase Cloud could not be reached. Try again later.'); + } + + $body = $response->json(); + if (!$response->successful() || !is_array($body) || empty($body['key'])) { + $reason = is_array($body) ? ($body['reason'] ?? null) : null; + $message = is_array($body) ? ($body['error'] ?? null) : null; + Log::warning('Hosted routing registration refused: ' . $response->status() . ' ' . ($reason ?? 'no reason')); + + return static::result(static::STATUS_FAILED, $reason ?? 'refused', $message ?? 'Registration was refused (HTTP ' . $response->status() . ').'); + } + + $apiHost = ($body['api_host'] ?? null) ?: config('fleetbase.hosted_routing.api_host'); + + Setting::configure(static::VALHALLA_KEY, array_merge($valhalla, [ + 'api_host' => $apiHost, + 'api_key' => $body['key'], + ])); + + $registration = [ + 'license' => data_get($body, 'license.uuid'), + 'key_hint' => substr((string) $body['key'], -4), + 'plan' => $body['plan'] ?? null, + 'email' => $email, + 'email_verified' => (bool) ($body['email_verified'] ?? false), + 'claimed' => (bool) ($body['claimed'] ?? false), + 'claim_url' => $body['claim_url'] ?? null, + 'api_host' => $apiHost, + 'registered_at' => now()->toIso8601String(), + ]; + Setting::configure(static::REGISTRATION_KEY, $registration); + + return static::result(static::STATUS_REGISTERED, null, 'Fleetbase Hosted Routing is on.') + [ + 'plan' => $registration['plan'], + 'email' => $email, + 'email_verified' => $registration['email_verified'], + 'claim_url' => $registration['claim_url'], + ]; + } + + /** + * @return array + */ + protected static function valhallaSettings(): array + { + $settings = Setting::lookup(static::VALHALLA_KEY, []); + + return is_array($settings) ? $settings : []; + } + + /** + * A Valhalla host of the admin's own, other than the public OSM demo + * servers (Fleet-Ops never uses those) and the hosted service. + * + * @param array $valhalla + */ + protected static function usesOwnServer(array $valhalla): bool + { + $host = trim((string) ($valhalla['api_host'] ?? '')); + if ($host === '' || static::isHostedHost($host)) { + return false; + } + + return !str_contains(strtolower((string) parse_url($host, PHP_URL_HOST)), 'openstreetmap.de'); + } + + /** + * @return array + */ + protected static function result(string $status, ?string $reason, string $message): array + { + return array_filter(['status' => $status, 'reason' => $reason, 'message' => $message], fn ($value) => $value !== null); + } +} diff --git a/src/Support/InstanceIdentity.php b/src/Support/InstanceIdentity.php new file mode 100644 index 00000000..32d5ad91 --- /dev/null +++ b/src/Support/InstanceIdentity.php @@ -0,0 +1,67 @@ + (string) Str::uuid(), + 'secret' => Str::random(64), + 'created_at' => now()->toIso8601String(), + ]; + + // firstOrCreate on the unique key (createOrFirst underneath): two + // processes racing on first use end up with the row written first. + $setting = Setting::firstOrCreate(['key' => static::SETTING_KEY], ['value' => $identity]); + + if (!static::isValid($setting?->value)) { + // A malformed row (edited by hand): replace it. + $setting = Setting::configure(static::SETTING_KEY, $identity); + } + + return $setting->value; + } + + public static function id(): string + { + return static::get()['id']; + } + + public static function secret(): string + { + return static::get()['secret']; + } + + protected static function isValid(mixed $identity): bool + { + return is_array($identity) && !empty($identity['id']) && !empty($identity['secret']); + } +} diff --git a/src/Webhook/Signer/EndpointSignature.php b/src/Webhook/Signer/EndpointSignature.php new file mode 100644 index 00000000..f24fdc53 --- /dev/null +++ b/src/Webhook/Signer/EndpointSignature.php @@ -0,0 +1,55 @@ +,v1=`. + * + * The HMAC-SHA256 covers "." with the endpoint's signing secret, so a + * receiver can verify the body it received byte for byte and reject old timestamps to stop + * replays. Receivers recompute the HMAC over the raw body (never a re-encoded one) and compare in + * constant time. The legacy `Signature` header, keyed on the API credential, is still sent. + */ +class EndpointSignature +{ + public const HEADER = 'X-Fleetbase-Signature'; + + public static function header(string $body, string $secret, ?int $timestamp = null): string + { + $timestamp ??= time(); + + return 't=' . $timestamp . ',v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret); + } + + /** + * Verify a signature header against a raw body. `$tolerance` is the accepted age in seconds. + */ + public static function verify(string $body, ?string $header, string $secret, int $tolerance = 300, ?int $now = null): bool + { + if (!$header || $secret === '') { + return false; + } + + $parts = []; + foreach (explode(',', $header) as $part) { + [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null); + if ($key !== null && $value !== null) { + $parts[$key][] = $value; + } + } + + $timestamp = isset($parts['t'][0]) && ctype_digit($parts['t'][0]) ? (int) $parts['t'][0] : null; + if ($timestamp === null || abs(($now ?? time()) - $timestamp) > $tolerance) { + return false; + } + + $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret); + foreach ($parts['v1'] ?? [] as $signature) { + if (hash_equals($expected, $signature)) { + return true; + } + } + + return false; + } +} diff --git a/src/Webhook/WebhookCall.php b/src/Webhook/WebhookCall.php index 9aa22802..52e8230e 100644 --- a/src/Webhook/WebhookCall.php +++ b/src/Webhook/WebhookCall.php @@ -7,6 +7,7 @@ use Fleetbase\Webhook\Exceptions\InvalidBackoffStrategy; use Fleetbase\Webhook\Exceptions\InvalidSigner; use Fleetbase\Webhook\Exceptions\InvalidWebhookJob; +use Fleetbase\Webhook\Signer\EndpointSignature; use Fleetbase\Webhook\Signer\Signer; use Illuminate\Foundation\Bus\PendingDispatch; use Illuminate\Support\Str; @@ -19,6 +20,8 @@ class WebhookCall protected string $secret; + protected ?string $endpointSecret = null; + protected Signer $signer; protected array $headers = []; @@ -103,6 +106,16 @@ public function useSecret(string $secret): self return $this; } + /** + * Also sign the delivery with the endpoint's own secret (X-Fleetbase-Signature). + */ + public function useEndpointSecret(?string $secret): self + { + $this->endpointSecret = $secret ?: null; + + return $this; + } + public function useHttpVerb(string $verb): self { $this->callWebhookJob->httpVerb = $verb; @@ -280,6 +293,16 @@ protected function getAllHeaders(): array $headers[$this->signer->signatureHeaderName()] = $signature; + // Signed over the exact body CallWebhookJob sends (json_encode of the payload) + // (skipped if a deployment configured the legacy header under the same name) + if ( + $this->endpointSecret !== null + && strtolower($this->callWebhookJob->httpVerb) !== 'get' + && strcasecmp($this->signer->signatureHeaderName(), EndpointSignature::HEADER) !== 0 + ) { + $headers[EndpointSignature::HEADER] = EndpointSignature::header(json_encode($this->payload), $this->endpointSecret); + } + return $headers; } } diff --git a/src/routes.php b/src/routes.php index 12d14411..b4ebb2e7 100644 --- a/src/routes.php +++ b/src/routes.php @@ -23,6 +23,44 @@ function ($router) { $router->get('/', 'Controller@hello'); + /* + |-------------------------------------------------------------------------- + | OAuth 2.0 / OpenID Connect provider ("Continue with Fleetbase") + |-------------------------------------------------------------------------- + | + | Apps sign people in with their Fleetbase account and, with consent, act on + | one organization. The consent screen itself is in the console, backed by + | the int/v1/oauth-server routes below. + */ + if (config('oauth-server.enabled', true)) { + $router->get('.well-known/openid-configuration', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'configuration']); + $router->get('.well-known/oauth-authorization-server', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'configuration']); + $router->prefix('oauth')->group(function ($router) { + $router->get('authorize', [Fleetbase\Http\Controllers\OAuthServer\AuthorizationController::class, 'start']); + $router->get('jwks', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'jwks']); + $router->middleware(['throttle:600,1'])->group(function ($router) { + $router->post('token', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'token']); + $router->post('revoke', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'revoke']); + }); + // RFC 7591, for software installed on many sites; see config oauth-server.registration. + $router->post('register', [Fleetbase\Http\Controllers\OAuthServer\RegistrationController::class, 'register']) + ->middleware(['throttle:10,1']); + $router->middleware(['throttle:600,1'])->group(function ($router) { + $router->match(['GET', 'POST'], 'userinfo', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'userinfo']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':openid'); + $router->get('v1/organization', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'organization']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':organization:read'); + $router->post('v1/api-key', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'apiKey']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':api-key:create'); + $router->delete('v1/api-key', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'revokeApiKey']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':api-key:create'); + // The scope is checked in the controller: each extension says which scope covers its types. + $router->match(['GET', 'PUT', 'DELETE'], 'v1/review-access/{type}/{id}', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'reviewAccess']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class); + }); + }); + } + /* |-------------------------------------------------------------------------- | Public/Consumable Routes @@ -79,6 +117,18 @@ function ($router) { $router->delete('remove-participant/{participantId}', 'ChatChannelController@removeParticipant'); } ); + // Webhook endpoints for API keys; signing secrets are only returned on create and rotate. + $router->group( + ['prefix' => 'webhook-endpoints'], + function ($router) { + $router->post('/', 'WebhookEndpointController@create'); + $router->get('/', 'WebhookEndpointController@query'); + $router->get('{id}', 'WebhookEndpointController@find'); + $router->put('{id}', 'WebhookEndpointController@update'); + $router->post('{id}/rotate-secret', 'WebhookEndpointController@rotateSecret'); + $router->delete('{id}', 'WebhookEndpointController@delete'); + } + ); $router->group( ['prefix' => 'comments'], function ($router) { @@ -192,6 +242,27 @@ function ($router) { function ($router) { // Realtime socket token for the signed-in console user. $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'token']); + // App Review access for whatever an extension registered (stores, Navigator, ...). + $router->get('review-access/{type}/{id}', 'ReviewAccessController@show'); + $router->put('review-access/{type}/{id}', 'ReviewAccessController@update'); + // "Continue with Fleetbase": the console's consent screen, Connected apps, + // and Admin → OAuth Apps (AdminRequest checks for a system admin). + $router->group(['prefix' => 'oauth-server'], function ($router) { + $router->get('authorization', 'OAuthConsentController@show'); + $router->post('authorization', 'OAuthConsentController@approve'); + $router->delete('authorization', 'OAuthConsentController@deny'); + $router->get('connected-apps', 'ConnectedAppController@index'); + $router->delete('connected-apps/{uuid}', 'ConnectedAppController@destroy'); + $router->get('apps', 'OAuthAppController@index'); + $router->post('apps', 'OAuthAppController@store'); + $router->get('apps/{id}', 'OAuthAppController@show'); + $router->put('apps/{id}', 'OAuthAppController@update'); + $router->post('apps/{id}/rotate-secret', 'OAuthAppController@rotateSecret'); + $router->post('apps/{id}/disable', 'OAuthAppController@disable'); + $router->post('apps/{id}/enable', 'OAuthAppController@enable'); + $router->get('apps/{id}/grants', 'OAuthAppController@grants'); + $router->delete('grants/{uuid}', 'OAuthAppController@revokeGrant'); + }); $router->group( ['prefix' => 'lookup'], function ($router) { @@ -315,6 +386,8 @@ function ($router, $controller) { function ($router, $controller) { $router->patch('enable/{id}', $controller('enable')); $router->patch('disable/{id}', $controller('disable')); + $router->get('{id}/secret', $controller('revealSecret')); + $router->post('{id}/rotate-secret', $controller('rotateSecret')); $router->get('events', $controller('events')); $router->get('versions', $controller('versions')); } diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php new file mode 100644 index 00000000..f8da100f --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php @@ -0,0 +1,402 @@ + 'Fleetbase', + 'fleetbase.console.host' => 'console.fleetbase.test', + 'fleetbase.console.secure' => true, + ]); + $container = Container::getInstance(); + $container->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), $container)); + Illuminate\Support\Facades\Facade::clearResolvedInstance('validator'); +}); +afterEach(fn () => oauth_test_teardown()); + +/** + * A console request from a signed-in user, with their Sanctum token's sign-in time. + */ +function oauth_console_request(string $userUuid, string $method = 'GET', array $input = [], string $signedIn = '-1 minute', string $class = Request::class): Request +{ + $user = User::where('uuid', $userUuid)->first(); + $user->withAccessToken((object) ['created_at' => Illuminate\Support\Carbon::parse($signedIn)]); + + $request = $class::create('/int/v1/oauth-server/authorization', $method, $input); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function oauth_console_consent(): OAuthConsentController +{ + return new OAuthConsentController(oauth_test_server()); +} + +/** + * Run a command with these arguments; returns [exit code, output]. + */ +function oauth_console_command(Illuminate\Console\Command $command, array $input = []): array +{ + $output = new BufferedOutput(); + $command->setLaravel(Container::getInstance()); + $command->setInput(new ArrayInput($input, $command->getDefinition())); + $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); + + return [$command->handle(), $output->fetch()]; +} + +describe('consent screen', function () { + it('describes the request: the app, the user, the scopes and the organizations they can share', function () { + [$client] = oauth_test_client(['logo_url' => 'https://app.example.com/logo.png']); + $response = oauth_console_consent()->show(oauth_console_request('user-admin', 'GET', oauth_test_params($client, oauth_test_verifier()) + ['utm_source' => 'mail'])); + $body = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and($body['client'])->toMatchArray(['id' => $client->id, 'name' => 'AppHelm', 'official' => true, 'redirect_host' => 'app.example.com']) + ->and($body['user']['email'])->toBe('admin@example.com') + ->and(array_column($body['scopes'], 'scope'))->toBe(['openid', 'profile', 'email', 'organization:read']) + ->and($body['scopes'][3]['description'])->toContain('{organization}') + ->and($body['requires_organization'])->toBeTrue() + ->and($body['organizations'])->toBe([['id' => 'company_a', 'name' => 'Acme Deliveries', 'logo_url' => $body['organizations'][0]['logo_url'], 'can_share' => true]]) + ->and($body['skip_consent'])->toBeFalse(); + }); + + it('preselects the organization the user shared with the app before', function () { + [$client] = oauth_test_client(); + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier, [], 'user-member', 'company-b'); + + $body = oauth_console_consent()->show(oauth_console_request('user-member', 'GET', oauth_test_params($client, oauth_test_verifier())))->getData(true); + + expect($body['selected_organization'])->toBe('company_b'); + }); + + it('asks for a fresh sign-in when the app wants one', function () { + [$client] = oauth_test_client(); + $params = oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'login']); + + $show = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', $params, '-1 hour')); + $approve = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'], '-1 hour')); + + expect($show->getData(true))->toBe(['reauthenticate' => true]) + ->and($approve->getStatusCode())->toBe(409); + }); + + it('returns an error the app can receive with where to send the user, and one it cannot without', function () { + [$client] = oauth_test_client(); + + $scope = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid nope']))); + $unknown = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', ['client_id' => 'nope'])); + + expect($scope->getStatusCode())->toBe(400) + ->and($scope->getData(true)['error'])->toBe('invalid_scope') + ->and($scope->getData(true)['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?error=invalid_scope') + ->and($unknown->getData(true))->toMatchArray(['error' => 'invalid_client', 'redirect' => null]); + }); + + it('approves with the organization picked, sends the user back with a code, and tells the account holder', function () { + [$client] = oauth_test_client(); + $params = oauth_test_params($client, oauth_test_verifier()); + + $response = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'])); + $again = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'])); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['redirect'])->toContain('code=') + ->and(OAuthGrant::count())->toBe(1) + ->and(OAuthGrant::first()->company_uuid)->toBe('company-a') + ->and(OAuthAuthCode::first()->auth_time)->not->toBeNull() + ->and($again->getStatusCode())->toBe(200); + }); + + it('refuses an organization the user does not administer', function () { + [$client] = oauth_test_client(); + + $response = oauth_console_consent()->approve(oauth_console_request('user-member', 'POST', oauth_test_params($client, oauth_test_verifier()) + ['organization' => 'company_a'])); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['error'])->toBe('access_denied') + ->and(OAuthGrant::count())->toBe(0); + }); + + it('sends a cancelled request back to the app', function () { + [$client] = oauth_test_client(); + + $deny = oauth_console_consent()->deny(oauth_console_request('user-owner', 'DELETE', oauth_test_params($client, oauth_test_verifier()))); + $invalid = oauth_console_consent()->deny(oauth_console_request('user-owner', 'DELETE', ['client_id' => 'nope'])); + + expect($deny->getData(true)['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?error=access_denied') + ->and($invalid->getStatusCode())->toBe(400); + }); +}); + +describe('the authorization endpoint', function () { + it('sends a valid request to the console consent screen, and errors where they belong', function () { + [$client] = oauth_test_client(); + $controller = new AuthorizationController(); + $authorize = fn (array $query) => $controller->start(Request::create('/oauth/authorize', 'GET', $query), oauth_test_server())->getTargetUrl(); + + expect($authorize(oauth_test_params($client, oauth_test_verifier())))->toStartWith('https://console.fleetbase.test/oauth/authorize?response_type=code&client_id=' . $client->id) + ->and($authorize(oauth_test_params($client, oauth_test_verifier(), ['response_type' => 'token'])))->toStartWith(OAUTH_TEST_REDIRECT . '?error=unsupported_response_type') + ->and($authorize(['client_id' => 'nope']))->toStartWith('https://console.fleetbase.test/oauth/authorize?error=invalid_client'); + }); + + it('hands token and revocation requests to the server', function () { + [$client, $secret] = oauth_test_client(); + $controller = new TokenController(); + + $token = $controller->token(Request::create('/oauth/token', 'POST', ['grant_type' => 'refresh_token', 'client_id' => $client->id, 'client_secret' => $secret, 'refresh_token' => 'x']), oauth_test_server()); + $revoke = $controller->revoke(Request::create('/oauth/revoke', 'POST', ['token' => 'x', 'client_id' => $client->id, 'client_secret' => $secret]), oauth_test_server()); + + expect($token->getStatusCode())->toBe(401) + ->and($revoke->getStatusCode())->toBe(200); + }); +}); + +describe('connected apps', function () { + it('lists the user\'s apps in plain words, and removes one', function () { + [$client] = oauth_test_client(['logo_url' => 'https://app.example.com/logo.png']); + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier); + $controller = new ConnectedAppController(); + + $apps = $controller->index(oauth_console_request('user-owner'))->getData(true)['connected_apps']; + + expect($apps)->toHaveCount(1) + ->and($apps[0]['app'])->toMatchArray(['name' => 'AppHelm', 'official' => true, 'disabled' => false]) + ->and($apps[0]['organization'])->toBe(['id' => 'company_a', 'name' => 'Acme Deliveries']) + ->and(array_column($apps[0]['scopes'], 'description'))->toContain("See Acme Deliveries's name, logo, currency and country") + ->and($controller->index(oauth_console_request('user-member'))->getData(true)['connected_apps'])->toBe([]); + + expect(fn () => $controller->destroy(oauth_console_request('user-member'), $apps[0]['id']))->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); + + $controller->destroy(oauth_console_request('user-owner'), $apps[0]['id']); + + expect(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(OAuthAuthCode::where('revoked', false)->count())->toBe(0); + }); +}); + +describe('admin: OAuth Apps', function () { + function oauth_admin_request(string $method = 'GET', array $input = []): AdminRequest + { + return oauth_console_request('user-owner', $method, $input, '-1 minute', AdminRequest::class); + } + + it('registers an app, showing the secret once, then edits it', function () { + $controller = new OAuthAppController(); + + $created = $controller->store(oauth_admin_request('POST', [ + 'name' => 'AppHelm', + 'redirect_uris' => [OAUTH_TEST_REDIRECT, OAUTH_TEST_REDIRECT], + 'allowed_scopes' => ['openid', 'organization:read', 'openid'], + 'official' => true, + ])); + $body = $created->getData(true); + $client = OAuthClient::find($body['app']['id']); + + expect($created->getStatusCode())->toBe(201) + ->and($body['client_secret'])->toBeString() + ->and($client->verifySecret($body['client_secret']))->toBeTrue() + ->and($client->redirect_uris)->toBe([OAUTH_TEST_REDIRECT]) + ->and($client->allowed_scopes)->toBe(['openid', 'organization:read']) + ->and($client->created_by_uuid)->toBe('user-owner') + ->and($body['app'])->not->toHaveKey('secret'); + + $updated = $controller->update(oauth_admin_request('PUT', ['name' => 'AppHelm 2', 'confidential' => false]), $client->id)->getData(true); + + expect($updated['app']['name'])->toBe('AppHelm 2') + ->and($updated['app']['confidential'])->toBeTrue(); + + $listed = $controller->index(oauth_admin_request())->getData(true); + $shown = $controller->show(oauth_admin_request(), $client->id)->getData(true); + + expect($listed['apps'][0]['active_grants_count'])->toBe(0) + ->and(array_column($listed['scopes'], 'scope'))->toContain('openid') + ->and($shown['app']['id'])->toBe($client->id); + }); + + it('registers a public app without a secret', function () { + $body = (new OAuthAppController())->store(oauth_admin_request('POST', [ + 'name' => 'Native', + 'redirect_uris' => ['http://127.0.0.1:3000/cb'], + 'allowed_scopes' => ['openid'], + 'development' => true, + 'confidential' => false, + ]))->getData(true); + + expect($body['client_secret'])->toBeNull() + ->and($body['app']['confidential'])->toBeFalse() + ->and((new OAuthAppController())->rotateSecret(oauth_admin_request('POST'), $body['app']['id'])->getStatusCode())->toBe(422); + }); + + it('refuses an unsafe redirect URI or an unknown scope', function () { + $register = fn (array $input) => (new OAuthAppController())->store(oauth_admin_request('POST', $input + ['name' => 'X', 'allowed_scopes' => ['openid'], 'redirect_uris' => [OAUTH_TEST_REDIRECT]])); + + expect(fn () => $register(['redirect_uris' => ['http://app.example.com/cb']]))->toThrow(ValidationException::class) + ->and(fn () => $register(['allowed_scopes' => ['admin:all']]))->toThrow(ValidationException::class) + ->and(OAuthClient::count())->toBe(0); + }); + + it('rotates a secret, turns an app off and on, and lists and revokes who authorized it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $tokens] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $controller = new OAuthAppController(); + + $rotated = $controller->rotateSecret(oauth_admin_request('POST'), $client->id)->getData(true); + expect($client->fresh()->verifySecret($secret))->toBeFalse() + ->and($client->fresh()->verifySecret($rotated['client_secret']))->toBeTrue(); + + $grants = $controller->grants(oauth_admin_request(), $client->id)->getData(true)['grants']; + expect($grants)->toHaveCount(1) + ->and($grants[0]['user'])->toBe(['id' => 'user_owner', 'name' => 'Olive Owner', 'email' => 'owner@example.com']) + ->and($grants[0]['organization'])->toBe(['id' => 'company_a', 'name' => 'Acme Deliveries']); + + $controller->disable(oauth_admin_request('POST'), $client->id); + expect($client->fresh()->isDisabled())->toBeTrue() + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(OAuthAccessToken::where('revoked', false)->count())->toBe(0) + ->and(OAuthRefreshToken::where('revoked', false)->count())->toBe(0); + + $controller->enable(oauth_admin_request('POST'), $client->id); + expect($client->fresh()->isDisabled())->toBeFalse() + ->and($tokens)->toHaveKey('access_token'); + + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier); + $grant = OAuthGrant::active()->first(); + $controller->revokeGrant(oauth_admin_request('DELETE'), $grant->uuid); + expect($grant->fresh()->isRevoked())->toBeTrue(); + }); +}); + +describe('the account holder notice', function () { + it('names the app, the organization and what it can do', function () { + $user = User::where('uuid', 'user-owner')->first(); + $organization = new OAuthAppAuthorized('AppHelm', 'Acme Deliveries', ['See your email address']); + $signIn = new OAuthAppAuthorized('AppHelm', null, ['Sign you in with your Fleetbase account']); + $mail = $organization->toMail($user); + + expect($organization->via($user))->toBe(['mail']) + ->and($mail->subject)->toBe('AppHelm can now access Acme Deliveries') + ->and($mail->introLines)->toContain('• See your email address') + ->and($mail->actionUrl)->toBe('https://console.fleetbase.test/account/auth') + ->and($signIn->toMail($user)->subject)->toBe('AppHelm can now sign you in with Fleetbase') + ->and($organization->toArray($user))->toMatchArray(['app' => 'AppHelm', 'organization' => 'Acme Deliveries', 'scopes' => ['See your email address']]); + }); +}); + +describe('artisan', function () { + it('registers, lists and rotates apps', function () { + [$code, $output] = oauth_console_command(new OAuthServerClient(), [ + 'action' => 'create', + '--name' => 'AppHelm', + '--redirect' => [OAUTH_TEST_REDIRECT], + '--scope' => ['openid', 'organization:read'], + '--official' => true, + ]); + $client = OAuthClient::first(); + + expect($code)->toBe(0) + ->and($output)->toContain('client_id: ' . $client->id) + ->and($output)->toContain('client_secret: ') + ->and($client->official)->toBeTrue() + ->and($client->allowed_scopes)->toBe(['openid', 'organization:read']); + + [$listCode, $list] = oauth_console_command(new OAuthServerClient(), ['action' => 'list']); + expect($listCode)->toBe(0)->and($list)->toContain('AppHelm')->and($list)->toContain('confidential'); + + [$rotateCode, $rotated] = oauth_console_command(new OAuthServerClient(), ['action' => 'rotate', 'id' => $client->id]); + expect($rotateCode)->toBe(0)->and($rotated)->toContain('old secret stopped working'); + }); + + it('registers a public app with every scope by default', function () { + [$code, $output] = oauth_console_command(new OAuthServerClient(), ['action' => 'create', '--name' => 'Native', '--redirect' => ['http://127.0.0.1:3000/cb'], '--public' => true, '--development' => true]); + + expect($code)->toBe(0) + ->and($output)->not->toContain('client_secret') + ->and(OAuthClient::first()->allowed_scopes)->toBe(array_keys(Fleetbase\Auth\OAuthServer\OAuthScopes::all())); + }); + + it('refuses what it cannot register or rotate', function () { + $run = fn (array $input) => oauth_console_command(new OAuthServerClient(), $input)[0]; + + expect($run(['action' => 'delete']))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X']))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X', '--redirect' => ['http://app.example.com/cb']]))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X', '--redirect' => [OAUTH_TEST_REDIRECT], '--scope' => ['admin:all']]))->toBe(1) + ->and($run(['action' => 'rotate', 'id' => 'nope']))->toBe(1) + ->and(OAuthClient::count())->toBe(0); + }); + + it('writes or prints a key pair, and will not overwrite one by accident', function () { + foreach (['private', 'public'] as $type) { + if (file_exists(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type))) { + unlink(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type)); + } + } + if (!is_dir(dirname(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')))) { + mkdir(dirname(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')), 0777, true); + } + + [$shownCode, $shown] = oauth_console_command(new OAuthServerKeys(), ['--show' => true]); + [$writeCode] = oauth_console_command(new OAuthServerKeys()); + [$againCode] = oauth_console_command(new OAuthServerKeys()); + [$forceCode] = oauth_console_command(new OAuthServerKeys(), ['--force' => true]); + + expect($shownCode)->toBe(0) + ->and($shown)->toContain('OAUTH_SERVER_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\n') + ->and($writeCode)->toBe(0) + ->and(file_get_contents(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')))->toContain('PRIVATE KEY') + ->and($againCode)->toBe(1) + ->and($forceCode)->toBe(0); + + foreach (['private', 'public'] as $type) { + unlink(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type)); + } + })->skip(fn () => !function_exists('openssl_pkey_new'), 'needs openssl'); + + it('prunes codes and tokens that expired more than a week ago', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $fresh = oauth_test_code($client, oauth_test_verifier()); + + OAuthAccessToken::query()->update(['expires_at' => now()->subDays(40)]); + OAuthRefreshToken::query()->update(['expires_at' => now()->subDays(40)]); + OAuthAuthCode::where('revoked', true)->update(['expires_at' => now()->subDays(40)]); + + [$code, $output] = oauth_console_command(new OAuthServerPrune(), ['--days' => '7']); + + expect($code)->toBe(0) + ->and($output)->toContain('Deleted 1 codes, 1 access tokens, 1 refresh tokens and 0 unused registered clients.') + ->and(OAuthAuthCode::count())->toBe(1) + ->and($fresh)->toBeString(); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php new file mode 100644 index 00000000..73c34145 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php @@ -0,0 +1,386 @@ + oauth_test_setup()); +afterEach(fn () => oauth_test_teardown()); + +function oauth_edge_authorize_error(array $params): AuthorizeError +{ + try { + oauth_test_server()->validateAuthorizationRequest($params); + } catch (AuthorizeError $e) { + return $e; + } + + throw new RuntimeException('Expected the authorization request to be refused.'); +} + +describe('authorization request checks', function () { + it('refuses malformed parameters, with the error going back to the app', function () { + [$client] = oauth_test_client(); + $params = fn (array $overrides) => oauth_test_params($client, oauth_test_verifier(), $overrides); + + $cases = [ + ['scope' => ['openid']], + ['response_type' => 'token'], + ['nonce' => str_repeat('n', 256)], + ['max_age' => 'soon'], + ['code_challenge' => 'too-short'], + ['prompt' => 'none consent'], + ]; + + $errors = array_map(fn ($overrides) => oauth_edge_authorize_error(array_merge($params([]), $overrides)), $cases); + + expect(array_map(fn ($error) => $error->error, $errors))->toBe(['invalid_request', 'unsupported_response_type', 'invalid_request', 'invalid_request', 'invalid_request', 'invalid_request']) + ->and(array_filter($errors, fn ($error) => !$error->canRedirect()))->toBe([]); + }); + + it('asks for a sign-in when it cannot tell when the user signed in', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + + expect($server->needsReauthentication($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'login'])), null))->toBeTrue() + ->and($server->needsReauthentication($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())), null))->toBeFalse(); + }); + + it('sends a declined request back to the app with access_denied and the state', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + + $url = $server->deny($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier()))); + + expect($url)->toStartWith(OAUTH_TEST_REDIRECT . '?error=access_denied') + ->and($url)->toContain('state=state-123') + ->and($url)->toContain('iss='); + }); + + it('keeps the query a redirect URI already has', function () { + $error = new AuthorizeError('access_denied', null, 'https://app.example.com/cb?tenant=1', 's'); + + expect($error->redirectUrl('https://api.fleetbase.test'))->toBe('https://app.example.com/cb?tenant=1&error=access_denied&state=s&iss=https%3A%2F%2Fapi.fleetbase.test'); + }); +}); + +describe('token endpoint', function () { + it('lets league reject a code or refresh token it cannot open', function () { + [$client, $secret] = oauth_test_client(); + + [$codeStatus, $code] = oauth_test_token(['grant_type' => 'authorization_code', 'client_id' => $client->id, 'client_secret' => $secret, 'code' => 'garbage', 'redirect_uri' => OAUTH_TEST_REDIRECT, 'code_verifier' => oauth_test_verifier()]); + [$refreshStatus, $refresh] = oauth_test_token(['grant_type' => 'refresh_token', 'client_id' => $client->id, 'client_secret' => $secret, 'refresh_token' => 'garbage']); + + expect($codeStatus)->toBe(400) + ->and($code['error'])->toBe('invalid_request') + ->and($refreshStatus)->toBe(401) + ->and($refresh['error'])->toBe('invalid_request'); + }); + + it('refuses a code whose grant was removed before it was redeemed', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + OAuthGrant::query()->update(['revoked_at' => now()]); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant'); + }); + + it('refuses a code once its user is deactivated, and drops the grant', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + User::where('uuid', 'user-owner')->update(['status' => 'inactive']); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); + + it('issues no ID token without openid', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => 'organization:read']), $verifier); + + expect($body)->not->toHaveKey('id_token') + ->and($body['scope'])->toBe('organization:read'); + }); + + it('issues no ID token for a user who has since been deleted', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['scope' => 'openid']); + + // Removing the user cascades to the grant in MySQL; here only the user row goes, so the + // response type is the one that notices. + User::where('uuid', 'user-owner')->forceDelete(); + $response = new Fleetbase\Auth\OAuthServer\IdTokenResponse(new IssueContext(), oauth_test_server()->keys(), 'https://api.fleetbase.test', new DateInterval('PT1H')); + $token = (new AccessTokenRepository(new IssueContext(), 'https://api.fleetbase.test'))->getNewToken(new ClientEntity($client->id, 'AppHelm', [OAUTH_TEST_REDIRECT], true), [new ScopeEntity('openid')], 'user-owner'); + $token->setPrivateKey(oauth_test_server()->keys()->privateKey()); + $token->setIdentifier('token-1'); + $token->setExpiryDateTime(new DateTimeImmutable('+1 hour')); + $response->setAccessToken($token); + $response->setEncryptionKey(oauth_test_server()->keys()->encryptionKey()); + + $body = json_decode((string) $response->generateHttpResponse(new Nyholm\Psr7\Response())->getBody(), true); + + expect($code)->toBeString() + ->and($body)->not->toHaveKey('id_token'); + }); +}); + +describe('revocation', function () { + it('needs a token', function () { + [$client, $secret] = oauth_test_client(); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['client_id' => $client->id, 'client_secret' => $secret])); + + expect($response->getStatusCode())->toBe(400); + }); + + it('ignores an access token signed by another key', function () { + [$client, $secret] = oauth_test_client(); + $other = OAuthKeys::generate(2048); + $config = Lcobucci\JWT\Configuration::forAsymmetricSigner(new Lcobucci\JWT\Signer\Rsa\Sha256(), Lcobucci\JWT\Signer\Key\InMemory::plainText($other['private']), Lcobucci\JWT\Signer\Key\InMemory::plainText($other['public'])); + $forged = $config->builder()->identifiedBy('forged')->getToken($config->signer(), $config->signingKey())->toString(); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $forged, 'client_id' => $client->id, 'client_secret' => $secret])); + + expect($response->getStatusCode())->toBe(200); + }); + + it('lets a public client revoke with its client_id alone, and refuses one that sends a secret', function () { + [$client] = oauth_test_client(['name' => 'Native'], confidential: false); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, null, oauth_test_code($client, $verifier), $verifier); + + $withSecret = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => 'x'])); + $plain = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $client->id])); + + expect($withSecret->getStatusCode())->toBe(401) + ->and($plain->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); +}); + +describe('protected resources', function () { + it('refuses a token whose row is gone', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + // A token row that is gone reads as revoked to league; one that is merely unusable (an + // expiry in the past that league's clock leeway still let through) reaches the row check. + Fleetbase\Models\OAuthAccessToken::query()->update(['expires_at' => now()->subMinute()]); + + $response = (new AuthenticateOAuthToken(oauth_test_server()))->handle(oauth_test_bearer($body['access_token']), fn () => throw new RuntimeException('must not run')); + + expect($response->getStatusCode())->toBe(401); + }); + + it('says when a route forgot the middleware or an organization scope', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => 'openid'], 'user-member', null), $verifier); + $request = oauth_test_bearer($body['access_token']); + + expect(fn () => AuthenticateOAuthToken::token(Request::create('/x')))->toThrow(LogicException::class); + + (new AuthenticateOAuthToken(oauth_test_server()))->handle($request, fn () => null); + + expect(fn () => AuthenticateOAuthToken::company($request))->toThrow(LogicException::class) + ->and($request->user()->uuid)->toBe('user-member'); + }); +}); + +describe('repositories', function () { + it('only know registered, enabled clients and the two supported grants', function () { + [$client, $secret] = oauth_test_client(); + [$public] = oauth_test_client(['name' => 'Native'], confidential: false); + $repository = new ClientRepository(); + + expect($repository->getClientEntity('00000000-0000-0000-0000-000000000000'))->toBeNull() + ->and($repository->getClientEntity('not-a-uuid'))->toBeNull() + ->and($repository->validateClient($client->id, $secret, 'client_credentials'))->toBeFalse() + ->and($repository->validateClient($client->id, $secret, 'authorization_code'))->toBeTrue() + ->and($repository->validateClient($public->id, null, 'refresh_token'))->toBeTrue() + ->and($repository->validateClient($public->id, 'secret', 'refresh_token'))->toBeFalse(); + }); + + it('finalize no scopes for a client that no longer exists', function () { + $client = new ClientEntity('00000000-0000-0000-0000-000000000000', 'Gone', [], true); + + expect((new ScopeRepository())->finalizeScopes([new ScopeEntity('openid')], 'authorization_code', $client))->toBe([]); + }); + + it('never issue a code or token outside a grant', function () { + [$client] = oauth_test_client(); + $entity = new ClientEntity($client->id, 'AppHelm', [OAUTH_TEST_REDIRECT], true); + + $code = new AuthCodeEntity(); + $code->setClient($entity); + $access = new AccessTokenEntity('https://api.fleetbase.test'); + $access->setClient($entity); + $refresh = new RefreshTokenEntity(); + $refresh->setAccessToken($access); + + expect(fn () => (new AuthCodeRepository(new IssueContext()))->persistNewAuthCode($code))->toThrow(LogicException::class) + ->and(fn () => (new AccessTokenRepository(new IssueContext(), 'https://api.fleetbase.test'))->persistNewAccessToken($access))->toThrow(LogicException::class) + ->and(fn () => (new RefreshTokenRepository(new IssueContext()))->persistNewRefreshToken($refresh))->toThrow(LogicException::class); + }); + + it('treat an unknown code or refresh token as revoked', function () { + expect((new AuthCodeRepository(new IssueContext()))->isAuthCodeRevoked('nope'))->toBeTrue() + ->and((new RefreshTokenRepository(new IssueContext()))->isRefreshTokenRevoked('nope'))->toBeTrue(); + }); + + it('open nothing from a payload that is not theirs', function () { + $crypt = new PayloadCrypt(oauth_test_server()->keys()->encryptionKey()); + + expect($crypt->open(null))->toBeNull() + ->and($crypt->open(''))->toBeNull() + ->and($crypt->open('not-encrypted'))->toBeNull(); + }); +}); + +describe('scopes registry', function () { + it('validates names, describes scopes with the organization, and maps review types', function () { + expect(fn () => OAuthScopes::register('Not Valid', []))->toThrow(InvalidArgumentException::class) + ->and(OAuthScopes::get('openid')['organization'])->toBeFalse() + ->and(OAuthScopes::get('missing'))->toBeNull() + ->and(OAuthScopes::scopeForReviewAccessType('storefront-store'))->toBe('storefront:review-access') + ->and(OAuthScopes::scopeForReviewAccessType('unknown'))->toBeNull() + ->and(OAuthScopes::describe(['storefront:read', 'missing'], 'Acme'))->toBe([ + ['scope' => 'storefront:read', 'description' => "See Acme's storefronts", 'organization' => true], + ]) + ->and(OAuthScopes::describe(['storefront:read'])[0]['description'])->toBe("See your organization's storefronts"); + }); +}); + +describe('keys', function () { + it('read key files from storage when no key is configured, and say when there are none', function () { + $keys = new OAuthKeys(); + $remove = function () { + foreach (['private', 'public'] as $type) { + if (file_exists(OAuthKeys::keyPath($type))) { + unlink(OAuthKeys::keyPath($type)); + } + } + }; + config(['oauth-server.private_key' => null, 'oauth-server.public_key' => null]); + if (!is_dir(dirname(OAuthKeys::keyPath('private')))) { + mkdir(dirname(OAuthKeys::keyPath('private')), 0777, true); + } + $remove(); + + expect($keys->configured())->toBeFalse() + ->and(fn () => $keys->privateKeyContents())->toThrow(RuntimeException::class); + + file_put_contents(OAuthKeys::keyPath('private'), oauth_test_keys()['private']); + file_put_contents(OAuthKeys::keyPath('public'), oauth_test_keys()['public']); + + expect($keys->configured())->toBeTrue() + ->and($keys->publicKeyContents())->toBe(oauth_test_keys()['public']); + + $remove(); + }); + + it('need APP_KEY for the payload key', function () { + config(['app.key' => '']); + + expect(fn () => (new OAuthKeys())->encryptionKey())->toThrow(RuntimeException::class); + }); +}); + +describe('clients and grants', function () { + it('accept only https redirect URIs, or loopback http on a development app', function () { + expect(OAuthClient::isAcceptableRedirectUri('https://app.example.com/cb'))->toBeTrue() + ->and(OAuthClient::isAcceptableRedirectUri('https://app.example.com/cb#x'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('not a url'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://app.example.com/cb', true))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://127.0.0.1:3000/cb'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://127.0.0.1:3000/cb', true))->toBeTrue() + ->and(OAuthClient::isAcceptableRedirectUri('myapp://callback', true))->toBeFalse() + ->and(OAuthClient::isLoopbackUri('https://127.0.0.1/cb'))->toBeFalse(); + }); + + it('match no empty redirect URI, and a public client has no secret to verify', function () { + [$client] = oauth_test_client(['name' => 'Native'], confidential: false); + + expect($client->allowsRedirectUri(null))->toBeFalse() + ->and($client->allowsRedirectUri(''))->toBeFalse() + ->and($client->verifySecret('anything'))->toBeFalse() + ->and($client->toArray()['confidential'])->toBeFalse(); + }); + + it('relate grants to their user, client and organization', function () { + [$client] = oauth_test_client(); + $grant = OAuthGrant::create(['user_uuid' => 'user-owner', 'client_id' => $client->id, 'company_uuid' => 'company-a', 'scopes' => ['openid']]); + + expect($grant->user->uuid)->toBe('user-owner') + ->and($grant->client->id)->toBe($client->id) + ->and($grant->company->uuid)->toBe('company-a') + ->and($client->grants()->count())->toBe(1); + + OAuthGrant::revokeTokensFor([]); + expect($grant->fresh()->isRevoked())->toBeFalse(); + }); +}); + +describe('organizations', function () { + it('can be shared only by an active member who administers them', function () { + OAuthOrganizations::$administratorCheck = null; + oauth_test_roles(['cu-admin-a' => 'Administrator', 'cu-member-a' => 'Dispatcher']); + + $companyA = Company::where('uuid', 'company-a')->first(); + $admin = User::where('uuid', 'user-admin')->first(); + $member = User::where('uuid', 'user-member')->first(); + $owner = User::where('uuid', 'user-owner')->first(); + + expect(OAuthOrganizations::canShare($admin, $companyA))->toBeTrue() + ->and(OAuthOrganizations::canShare($member, $companyA))->toBeFalse() + ->and(OAuthOrganizations::canShare($owner, Company::where('uuid', 'company-b')->first()))->toBeFalse(); + + $admin->status = 'inactive'; + expect(OAuthOrganizations::canShare($admin, $companyA))->toBeFalse(); + }); + + it('list the user\'s organizations for the picker and find one by public id', function () { + $member = User::where('uuid', 'user-member')->first(); + + expect(OAuthOrganizations::forPicker($member)->all())->toBe([ + ['id' => 'company_a', 'uuid' => 'company-a', 'name' => 'Acme Deliveries', 'logo_url' => Company::where('uuid', 'company-a')->first()->logo_url, 'can_share' => false], + ['id' => 'company_b', 'uuid' => 'company-b', 'name' => 'Bravo Freight', 'logo_url' => Company::where('uuid', 'company-b')->first()->logo_url, 'can_share' => true], + ]) + ->and(OAuthOrganizations::find($member, 'company_b')?->uuid)->toBe('company-b') + ->and(OAuthOrganizations::find($member, null))->toBeNull() + ->and(OAuthOrganizations::find(User::where('uuid', 'user-admin')->first(), 'company_b'))->toBeNull(); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php new file mode 100644 index 00000000..19e41049 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php @@ -0,0 +1,173 @@ +instance('hash', new class { + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + }); + $container->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('hash'); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + + (require __DIR__ . '/../../../../migrations/2026_10_24_000000_create_review_accesses_table.php')->up(); + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + // An organization-level review type, the way fleetops registers Navigator, covered by a scope. + AppReview::register('test-navigator', ['model' => Company::class, 'phone' => '+12025550144', 'name' => 'Navigator']); + OAuthScopes::register('navigator:review-access', ['description' => 'Turn App Review sign-in on or off', 'organization' => true, 'review_access_types' => ['test-navigator']]); +}); + +afterEach(function () { + AppReview::forget('test-navigator'); + oauth_test_teardown(); +}); + +/** + * Sign in user-owner to company A with these scopes; returns the access token. + */ +function oauth_endpoint_token(string $scope, string $userUuid = 'user-owner', string $companyUuid = 'company-a'): string +{ + [$client, $secret] = oauth_test_client(['allowed_scopes' => array_keys(OAuthScopes::all())]); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => $scope], $userUuid, $companyUuid), $verifier); + + return $body['access_token']; +} + +/** + * Run a request through the middleware and controller action, as the router would. + */ +function oauth_endpoint_call(Request $request, array $scopes, Closure $action) +{ + return (new AuthenticateOAuthToken(oauth_test_server()))->handle($request, $action, ...$scopes); +} + +it('publishes discovery metadata that matches the endpoints and the signing key', function () { + $metadata = (new DiscoveryController())->configuration(oauth_test_server())->getData(true); + $jwks = (new DiscoveryController())->jwks(oauth_test_server())->getData(true); + + expect($metadata['issuer'])->toBe('https://api.fleetbase.test') + ->and($metadata['authorization_endpoint'])->toBe('https://api.fleetbase.test/oauth/authorize') + ->and($metadata['token_endpoint'])->toBe('https://api.fleetbase.test/oauth/token') + ->and($metadata['code_challenge_methods_supported'])->toBe(['S256']) + ->and($metadata['grant_types_supported'])->toBe(['authorization_code', 'refresh_token']) + ->and($metadata['scopes_supported'])->toContain('openid', 'organization:read', 'storefront:read') + ->and($jwks['keys'][0]['kid'])->toBe(oauth_test_server()->keys()->keyId()) + ->and($jwks['keys'][0])->toHaveKeys(['n', 'e']) + ->and($jwks['keys'][0])->not->toHaveKey('d'); +}); + +it('returns the organization on the token, not the user\'s current one', function () { + $token = oauth_endpoint_token('openid organization:read'); + Fleetbase\Models\User::where('uuid', 'user-owner')->update(['company_uuid' => 'company-b']); + + $response = oauth_endpoint_call(oauth_test_bearer($token), ['organization:read'], fn ($request) => (new ResourceController())->organization($request)); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['organization'])->toMatchArray([ + 'id' => 'company_a', + 'name' => 'Acme Deliveries', + 'currency' => 'USD', + 'country' => 'US', + ]); +}); + +it('answers userinfo with the claims the scopes allow', function () { + $token = oauth_endpoint_token('openid email'); + + $claims = oauth_endpoint_call(oauth_test_bearer($token, '/oauth/userinfo'), ['openid'], fn ($request) => (new ResourceController())->userinfo($request))->getData(true); + + expect($claims)->toBe(['sub' => 'user_owner', 'email' => 'owner@example.com', 'email_verified' => true]); +}); + +it('refuses a request without the route\'s scope, with an RFC 6750 challenge', function () { + $token = oauth_endpoint_token('openid email'); + + $response = oauth_endpoint_call(oauth_test_bearer($token), ['organization:read'], fn () => throw new RuntimeException('must not run')); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe('insufficient_scope') + ->and($response->headers->get('WWW-Authenticate'))->toStartWith('Bearer error="insufficient_scope"'); +}); + +describe('review access', function () { + function oauth_review_call(string $token, string $method, string $id = 'company_a', array $input = []) + { + $request = Request::create('/oauth/v1/review-access/test-navigator/' . $id, $method, $input, [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]); + + return oauth_endpoint_call($request, [], fn ($request) => (new ResourceController())->reviewAccess($request, 'test-navigator', $id)); + } + + it('turns access on with a code shown once, reads it without the code, and turns it off', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + + $on = oauth_review_call($token, 'PUT')->getData(true)['review_access']; + expect($on['enabled'])->toBeTrue() + ->and($on['phone'])->toBe('+12025550144') + ->and($on['code'])->toMatch('/^\d{6}$/'); + + $again = oauth_review_call($token, 'PUT')->getData(true)['review_access']; + $read = oauth_review_call($token, 'GET')->getData(true)['review_access']; + expect($again)->not->toHaveKey('code') + ->and($read)->not->toHaveKey('code') + ->and($read['has_code'])->toBeTrue(); + + $regenerated = oauth_review_call($token, 'PUT', 'company_a', ['regenerate_code' => true])->getData(true)['review_access']; + expect($regenerated['code'])->toMatch('/^\d{6}$/'); + + $off = oauth_review_call($token, 'DELETE')->getData(true)['review_access']; + expect($off['enabled'])->toBeFalse() + ->and($off['has_code'])->toBeTrue(); + }); + + it('needs the scope that covers the type', function () { + $token = oauth_endpoint_token('openid organization:read'); + + $response = oauth_review_call($token, 'PUT'); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe('insufficient_scope'); + }); + + it('does not know a type nobody registered', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + $request = Request::create('/oauth/v1/review-access/unknown/company_a', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]); + + $response = oauth_endpoint_call($request, [], fn ($request) => (new ResourceController())->reviewAccess($request, 'unknown', 'company_a')); + + expect($response->getStatusCode())->toBe(404); + }); + + it('cannot reach another organization', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + + expect(oauth_review_call($token, 'PUT', 'company_b')->getStatusCode())->toBe(404) + ->and(ReviewAccess::count())->toBe(0); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php new file mode 100644 index 00000000..1acf0e41 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php @@ -0,0 +1,512 @@ + oauth_test_setup()); +afterEach(fn () => oauth_test_teardown()); + +function oauth_flow_authorize_error(array $params): AuthorizeError +{ + try { + oauth_test_server()->validateAuthorizationRequest($params); + } catch (AuthorizeError $e) { + return $e; + } + + throw new RuntimeException('Expected the authorization request to be refused.'); +} + +function oauth_flow_token_error(Request $request, array $scopes = []): TokenError +{ + try { + oauth_test_server()->authenticate($request, $scopes); + } catch (TokenError $e) { + return $e; + } + + throw new RuntimeException('Expected the access token to be refused.'); +} + +describe('the full flow', function () { + it('signs in, issues a signed ID token, and binds the tokens to the chosen organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + expect($status)->toBe(200) + ->and($body)->toHaveKeys(['access_token', 'refresh_token', 'id_token', 'expires_in']) + ->and($body['token_type'])->toBe('Bearer') + ->and(explode(' ', $body['scope']))->toEqualCanonicalizing(['openid', 'profile', 'email', 'organization:read']); + + $jwt = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText('empty', 'empty')); + $idToken = $jwt->parser()->parse($body['id_token']); + $claims = $idToken->claims(); + + expect($jwt->validator()->validate($idToken, new SignedWith(new Sha256(), InMemory::plainText(oauth_test_keys()['public']))))->toBeTrue() + ->and($claims->get('iss'))->toBe('https://api.fleetbase.test') + ->and($claims->get('aud'))->toBe([$client->id]) + ->and($claims->get('sub'))->toBe('user_owner') + ->and($claims->get('nonce'))->toBe('nonce-abc') + ->and($claims->get('email'))->toBe('owner@example.com') + ->and($claims->get('email_verified'))->toBeTrue() + ->and($claims->get('name'))->toBe('Olive Owner') + ->and($claims->get('org_id'))->toBe('company_a') + ->and($claims->get('at_hash'))->toBe(IdTokenResponse::accessTokenHash($body['access_token'])) + ->and($claims->has('auth_time'))->toBeTrue() + ->and($idToken->headers()->get('kid'))->toBe(oauth_test_server()->keys()->keyId()); + + $token = oauth_test_server()->authenticate(oauth_test_bearer($body['access_token']), ['organization:read']); + + expect($token->company_uuid)->toBe('company-a') + ->and($token->user_uuid)->toBe('user-owner') + ->and($token->grant->scopes)->toContain('organization:read'); + }); + + it('sends iss and state back with the code (RFC 9207)', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $result = $server->approve( + $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())), + User::where('uuid', 'user-owner')->first(), + Company::where('uuid', 'company-a')->first(), + null + ); + + parse_str((string) parse_url($result['redirect'], PHP_URL_QUERY), $query); + + expect($result['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?') + ->and($query['state'])->toBe('state-123') + ->and($query['iss'])->toBe('https://api.fleetbase.test') + ->and($query)->toHaveKey('code') + ->and($result['created'])->toBeTrue(); + }); + + it('signs in without an organization when only identity scopes are asked for', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['scope' => 'openid email'], 'user-member', 'company-a'); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + $token = oauth_test_server()->authenticate(oauth_test_bearer($body['access_token'])); + + expect($status)->toBe(200) + ->and($token->company_uuid)->toBeNull() + ->and(OAuthGrant::where('user_uuid', 'user-member')->first()->company_uuid)->toBeNull(); + }); + + it('skips the consent screen only for a sign-in the user already approved', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $user = User::where('uuid', 'user-member')->first(); + $signIn = fn (array $overrides = []) => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), array_merge(['scope' => 'openid email'], $overrides))); + + expect($server->canSkipConsent($signIn(), $user))->toBeFalse(); + + $server->approve($signIn(), $user, null, null); + + expect($server->canSkipConsent($signIn(), $user))->toBeTrue() + ->and($server->canSkipConsent($signIn(['prompt' => 'consent']), $user))->toBeFalse() + ->and($server->canSkipConsent($signIn(['scope' => 'openid email profile']), $user))->toBeFalse() + ->and($server->canSkipConsent($signIn(['scope' => 'openid organization:read']), $user))->toBeFalse(); + }); +}); + +describe('PKCE', function () { + it('is required, even for a confidential client', function () { + [$client] = oauth_test_client(); + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['code_challenge' => null, 'code_challenge_method' => null])); + + expect($error->error)->toBe('invalid_request') + ->and($error->canRedirect())->toBeTrue() + ->and($error->redirectUrl('https://api.fleetbase.test'))->toContain('state=state-123'); + }); + + it('accepts only S256', function () { + [$client] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $error = oauth_flow_authorize_error(oauth_test_params($client, $verifier, ['code_challenge' => $verifier, 'code_challenge_method' => 'plain'])); + + expect($error->error)->toBe('invalid_request') + ->and($error->description)->toContain('S256'); + }); + + it('refuses a code redeemed with the wrong verifier', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), oauth_test_verifier()); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant'); + }); + + it('refuses a code redeemed with no verifier', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier, ['code_verifier' => null]); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request'); + }); + + it('lets a public client sign in with PKCE alone', function () { + [$client] = oauth_test_client(['name' => 'Native app'], confidential: false); + $verifier = oauth_test_verifier(); + [$status, $body] = oauth_test_exchange($client, null, oauth_test_code($client, $verifier), $verifier); + + expect($client->isConfidential())->toBeFalse() + ->and($status)->toBe(200) + ->and($body)->toHaveKey('access_token'); + }); +}); + +describe('redirect URIs', function () { + it('never redirects to an unregistered URI', function () { + [$client] = oauth_test_client(); + + foreach ([ + 'https://evil.example.com/oauth/callback', + OAUTH_TEST_REDIRECT . '?extra=1', + OAUTH_TEST_REDIRECT . '/', + 'http://app.example.com/oauth/callback', + ] as $uri) { + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['redirect_uri' => $uri])); + + expect($error->error)->toBe('invalid_request') + ->and($error->canRedirect())->toBeFalse() + ->and($error->redirectUrl('https://api.fleetbase.test'))->toBeNull(); + } + }); + + it('requires a redirect URI on every request', function () { + [$client] = oauth_test_client(); + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['redirect_uri' => null])); + + expect($error->canRedirect())->toBeFalse(); + }); + + it('refuses an unknown or disabled client without redirecting', function () { + [$client] = oauth_test_client(); + $client->forceFill(['disabled_at' => now()])->save(); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier()))->error)->toBe('invalid_client') + ->and(oauth_flow_authorize_error(['client_id' => 'not-a-client'])->canRedirect())->toBeFalse(); + }); + + it('lets a development client use any loopback port, and nobody else', function () { + [$dev] = oauth_test_client(['redirect_uris' => ['http://127.0.0.1:3000/callback'], 'development' => true]); + [$prod] = oauth_test_client(['redirect_uris' => ['http://127.0.0.1:3000/callback'], 'development' => false]); + + expect($dev->allowsRedirectUri('http://127.0.0.1:51234/callback'))->toBeTrue() + ->and($dev->allowsRedirectUri('http://127.0.0.1:51234/other'))->toBeFalse() + ->and($prod->allowsRedirectUri('http://127.0.0.1:51234/callback'))->toBeFalse() + ->and($prod->allowsRedirectUri('http://127.0.0.1:3000/callback'))->toBeTrue(); + }); + + it('refuses a code redeemed with a different redirect URI', function () { + [$client, $secret] = oauth_test_client(['redirect_uris' => [OAUTH_TEST_REDIRECT, 'https://app.example.com/other']]); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier, ['redirect_uri' => 'https://app.example.com/other']); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request'); + }); +}); + +describe('authorization codes', function () { + it('expire', function () { + config(['oauth-server.ttl.auth_code' => 'PT1S']); + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + sleep(2); + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request') + ->and($body['hint'] ?? $body['error_description'])->toContain('expired'); + }); + + it('work once, and a replay revokes the tokens already issued from it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + [, $first] = oauth_test_exchange($client, $secret, $code, $verifier); + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request') + ->and(oauth_flow_token_error(oauth_test_bearer($first['access_token']))->error)->toBe('invalid_token'); + + [$refreshStatus] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $first['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + expect($refreshStatus)->toBe(401); + }); + + it('belong to the client they were issued to', function () { + [$client] = oauth_test_client(); + [$other, $otherSecret] = oauth_test_client(['name' => 'Other app']); + $verifier = oauth_test_verifier(); + + [$status] = oauth_test_exchange($other, $otherSecret, oauth_test_code($client, $verifier), $verifier); + + expect($status)->toBe(400); + }); +}); + +describe('scopes', function () { + it('refuses scopes the client was not registered for, or that do not exist', function () { + [$client] = oauth_test_client(['allowed_scopes' => ['openid', 'email']]); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid organization:read']))->error)->toBe('invalid_scope') + ->and(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid admin:everything']))->error)->toBe('invalid_scope') + ->and(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => '']))->error)->toBe('invalid_scope'); + }); + + it('refuses an endpoint that needs a scope the token does not have', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $error = oauth_flow_token_error(oauth_test_bearer($body['access_token']), ['storefront:read']); + + expect($error->error)->toBe('insufficient_scope') + ->and($error->status)->toBe(403) + ->and($error->toResponse()->headers->get('WWW-Authenticate'))->toContain('scope="storefront:read"'); + }); + + it('loses a scope at once when an admin removes it from the app', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $client->forceFill(['allowed_scopes' => ['openid', 'profile', 'email']])->save(); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']), ['organization:read'])->error)->toBe('insufficient_scope'); + }); + + it('cannot widen scopes on refresh', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + [$status, $refreshed] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret, 'scope' => 'openid storefront:read']); + + expect($status)->toBe(400) + ->and($refreshed['error'])->toBe('invalid_scope'); + }); + + it('needs an organization the user administers for organization scopes', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $request = fn () => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())); + $member = User::where('uuid', 'user-member')->first(); + $admin = User::where('uuid', 'user-admin')->first(); + $companyA = Company::where('uuid', 'company-a')->first(); + $companyB = Company::where('uuid', 'company-b')->first(); + + expect(fn () => $server->approve($request(), $member, null, null))->toThrow(AuthorizeError::class) + ->and(fn () => $server->approve($request(), $member, $companyA, null))->toThrow(AuthorizeError::class) + ->and(fn () => $server->approve($request(), $admin, $companyB, null))->toThrow(AuthorizeError::class) + ->and($server->approve($request(), $admin, $companyA, null)['grant']->company_uuid)->toBe('company-a') + ->and($server->approve($request(), $member, $companyB, null)['grant']->company_uuid)->toBe('company-b'); + }); +}); + +describe('refresh tokens', function () { + it('rotate, and a replayed one revokes the whole grant', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $first] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $refresh = fn (string $token) => oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $token, 'client_id' => $client->id, 'client_secret' => $secret]); + + [$status, $second] = $refresh($first['refresh_token']); + expect($status)->toBe(200) + ->and($second['refresh_token'])->not->toBe($first['refresh_token']) + ->and(oauth_test_server()->authenticate(oauth_test_bearer($second['access_token']))->company_uuid)->toBe('company-a') + ->and(oauth_flow_token_error(oauth_test_bearer($first['access_token']))->error)->toBe('invalid_token'); + + [$replayStatus] = $refresh($first['refresh_token']); + expect($replayStatus)->toBe(401) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(oauth_flow_token_error(oauth_test_bearer($second['access_token']))->error)->toBe('invalid_token'); + + [$afterStatus] = $refresh($second['refresh_token']); + expect($afterStatus)->toBe(401); + }); + + it('stop working when the user no longer administers the organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, [], 'user-admin'), $verifier); + + Fleetbase\Auth\OAuthServer\OAuthOrganizations::$administratorCheck = fn () => false; + [$status, $error] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($status)->toBe(400) + ->and($error['error'])->toBe('invalid_grant') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); +}); + +describe('access to the organization', function () { + it('ends when the user leaves the organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + Fleetbase\Models\CompanyUser::where('uuid', 'cu-owner-a')->delete(); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); + + it('ends when the account is deactivated', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + User::where('uuid', 'user-owner')->update(['status' => 'inactive']); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token'); + }); + + it('is refused for a forged or tampered token', function () { + $other = Fleetbase\Auth\OAuthServer\OAuthKeys::generate(2048); + $config = Configuration::forAsymmetricSigner(new Sha256(), InMemory::plainText($other['private']), InMemory::plainText($other['public'])); + $now = new DateTimeImmutable('@' . time()); + $forged = $config->builder()->identifiedBy('forged')->permittedFor('x')->relatedTo('user-owner')->issuedAt($now)->canOnlyBeUsedAfter($now)->expiresAt($now->modify('+1 hour'))->withClaim('scopes', ['organization:read'])->getToken($config->signer(), $config->signingKey()); + + expect(oauth_flow_token_error(oauth_test_bearer($forged->toString()))->error)->toBe('invalid_token') + ->and(oauth_flow_token_error(Request::create('/oauth/v1/organization'))->error)->toBe('invalid_token'); + }); +}); + +describe('revocation', function () { + it('of a refresh token disconnects the app', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'token_type_hint' => 'refresh_token'], [], [], [ + 'HTTP_AUTHORIZATION' => 'Basic ' . base64_encode($client->id . ':' . $secret), + ])); + + expect($response->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token'); + }); + + it('of an access token revokes only that token', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['access_token'], 'client_id' => $client->id, 'client_secret' => $secret])); + + [$status] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($response->getStatusCode())->toBe(200) + ->and(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and(OAuthGrant::first()->isRevoked())->toBeFalse() + ->and($status)->toBe(200); + }); + + it('needs the client to authenticate, and ignores other clients\' tokens', function () { + [$client, $secret] = oauth_test_client(); + [$other, $otherSecret] = oauth_test_client(['name' => 'Other app']); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $unauthenticated = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['access_token'], 'client_id' => $client->id, 'client_secret' => 'wrong'])); + $otherClient = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $other->id, 'client_secret' => $otherSecret])); + $unknown = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => 'not-a-token', 'client_id' => $client->id, 'client_secret' => $secret])); + + expect($unauthenticated->getStatusCode())->toBe(401) + ->and($otherClient->getStatusCode())->toBe(200) + ->and($unknown->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeFalse() + ->and(oauth_test_server()->authenticate(oauth_test_bearer($body['access_token']))->user_uuid)->toBe('user-owner'); + }); + + it('of a grant from Connected apps stops every token under it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + OAuthGrant::first()->revoke(); + + [$status] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and($status)->toBe(401) + ->and(OAuthAccessToken::where('revoked', false)->count())->toBe(0); + }); +}); + +describe('clients', function () { + it('authenticate with their secret, which rotation replaces', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + [$wrongStatus, $wrong] = oauth_test_exchange($client, 'not-the-secret', $code, $verifier); + expect($wrongStatus)->toBe(401)->and($wrong['error'])->toBe('invalid_client'); + + $newSecret = $client->issueSecret(); + $client->save(); + + [$oldStatus] = oauth_test_exchange($client, $secret, $code, $verifier); + [$newStatus] = oauth_test_exchange($client, $newSecret, $code, $verifier); + + expect($oldStatus)->toBe(401) + ->and($newStatus)->toBe(200) + ->and($client->fresh()->secret)->not->toBe($newSecret) + ->and($client->fresh()->toArray())->not->toHaveKey('secret'); + }); + + it('cannot use grants other than authorization_code and refresh_token', function () { + [$client, $secret] = oauth_test_client(); + + [$status, $body] = oauth_test_token(['grant_type' => 'client_credentials', 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('unsupported_grant_type'); + }); +}); + +describe('prompt and max_age', function () { + it('asks for a fresh sign-in when prompt=login or max_age says so', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $request = fn (array $overrides) => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), $overrides)); + + expect($server->needsReauthentication($request(['prompt' => 'login']), new DateTimeImmutable('-10 minutes')))->toBeTrue() + ->and($server->needsReauthentication($request(['prompt' => 'login']), new DateTimeImmutable('-1 minute')))->toBeFalse() + ->and($server->needsReauthentication($request(['max_age' => '60']), new DateTimeImmutable('-2 minutes')))->toBeTrue() + ->and($server->needsReauthentication($request([]), new DateTimeImmutable('-1 day')))->toBeFalse(); + }); + + it('answers prompt=none with interaction_required', function () { + [$client] = oauth_test_client(); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'none']))->error)->toBe('interaction_required'); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerRegistrationTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerRegistrationTest.php new file mode 100644 index 00000000..34ddd4ce --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerRegistrationTest.php @@ -0,0 +1,489 @@ +getSchemaBuilder()->create('api_credentials', function ($table) { + $table->increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid')->nullable()->unique(); + $table->string('user_uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('key')->nullable(); + $table->string('secret')->nullable(); + $table->integer('test_mode')->default(0); + $table->string('api')->nullable(); + $table->json('browser_origins')->nullable(); + $table->dateTime('last_used_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + (require __DIR__ . '/../../../../migrations/2026_10_27_000001_add_oauth_registration_columns.php')->up(); + config([ + 'oauth-server.registration' => [ + 'enabled' => true, + 'unused_client_hours' => 24, + 'software' => [ + 'fleetbase-woocommerce' => [ + 'name' => 'Fleetbase for WooCommerce', + 'description' => 'Delivery for WooCommerce.', + 'website_url' => 'https://github.com/fleetbase/woocommerce', + 'scopes' => ['organization:read', 'api-key:create', 'not-a-registered-scope'], + ], + ], + ], + ]); +}); +afterEach(function () { + Carbon::setTestNow(); + oauth_test_teardown(); +}); + +/** + * POST /oauth/register. Returns [status, decoded body]. + */ +function oauth_register(array $body): array +{ + $response = (new RegistrationController())->register(Request::create('/oauth/register', 'POST', $body)); + + return [$response->getStatusCode(), $response->getData(true)]; +} + +/** + * The observer that generates keys on api_credentials, as in production. + */ +function oauth_registration_api_credentials(): void +{ + Container::getInstance()->instance('hash', new class { + public function make(string $value): string + { + return 'hashed:' . $value; + } + }); + // The test cache has no tags, so keep only the listeners that shape the row: the uuid + // (HasUuid) and the generated key (ApiCredentialObserver), as in production. + ApiCredential::flushEventListeners(); + ApiCredential::creating(fn (ApiCredential $credential) => $credential->uuid ??= (string) Illuminate\Support\Str::uuid()); + ApiCredential::created(fn (ApiCredential $credential) => (new ApiCredentialObserver())->created($credential)); +} + +/** + * Run a registered public client through consent and the token endpoint, as a plugin does. + */ +function oauth_registration_tokens(OAuthClient $client, string $userUuid = 'user-owner', string $companyUuid = 'company-a'): array +{ + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, [ + 'redirect_uri' => OAUTH_REGISTRATION_REDIRECT, + 'scope' => 'organization:read api-key:create', + 'nonce' => null, + ], $userUuid, $companyUuid); + + return oauth_test_exchange($client, null, $code, $verifier, ['redirect_uri' => OAUTH_REGISTRATION_REDIRECT]); +} + +/** + * Call /oauth/v1/api-key the way the route does: the middleware first, then the controller. + */ +function oauth_registration_api_key(string $accessToken, array $headers = [], string $method = 'POST', array $input = []): array +{ + $server = []; + foreach ($headers as $name => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + $request = Request::create('/oauth/v1/api-key', $method, $input, [], [], array_merge(['HTTP_AUTHORIZATION' => 'Bearer ' . $accessToken], $server)); + Container::getInstance()->instance('request', $request); + + $controller = new ResourceController(); + $response = (new AuthenticateOAuthToken(oauth_test_server()))->handle( + $request, + fn (Request $request) => $method === 'DELETE' ? $controller->revokeApiKey($request) : $controller->apiKey($request), + 'api-key:create' + ); + + return [$response->getStatusCode(), json_decode($response->getContent() ?: 'null', true)]; +} + +/** + * Tokens for a pre-registered official app that serves many installations (the Shopify app's shape). + */ +function oauth_registration_shared_client_tokens(): array +{ + [$client, $secret] = oauth_test_client(['name' => 'Fleetbase for Shopify', 'allowed_scopes' => ['organization:read', 'api-key:create']]); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['scope' => 'organization:read api-key:create', 'nonce' => null]); + [, $tokens] = oauth_test_exchange($client, $secret, $code, $verifier); + + return [$client, $tokens['access_token']]; +} + +describe('registration', function () { + it('registers a public client for one site, named after its host and never official', function () { + [$status, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + + $client = OAuthClient::find($body['client_id']); + + expect($status)->toBe(201) + ->and($body)->toMatchArray([ + 'client_name' => 'Fleetbase for WooCommerce (shop.example.com)', + 'software_id' => 'fleetbase-woocommerce', + 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + 'token_endpoint_auth_method' => 'none', + 'scope' => 'organization:read api-key:create', + ]) + ->and($body['client_id_issued_at'])->toBeInt() + ->and($client->isConfidential())->toBeFalse() + ->and($client->official)->toBeFalse() + ->and($client->development)->toBeFalse() + ->and($client->isRegistered())->toBeTrue() + ->and($client->allowed_scopes)->toBe(['organization:read', 'api-key:create']) + ->and($client->website_url)->toBe('https://github.com/fleetbase/woocommerce'); + }); + + it('returns the same client when the same site registers again', function () { + [, $first] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + [$status, $second] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + [, $otherSite] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => ['https://other.example.com/wp-admin/admin-post.php?action=fleetbase_wc_oauth']]); + + expect($status)->toBe(200) + ->and($second['client_id'])->toBe($first['client_id']) + ->and($otherSite['client_id'])->not->toBe($first['client_id']) + ->and(OAuthClient::whereNotNull('software_id')->count())->toBe(2); + }); + + it('lets a loopback http site register as a development client', function () { + [$status, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => ['http://localhost:8800/wp-admin/admin-post.php?action=fleetbase_wc_oauth']]); + + expect($status)->toBe(201) + ->and($body['client_name'])->toBe('Fleetbase for WooCommerce (localhost)') + ->and(OAuthClient::find($body['client_id'])->development)->toBeTrue(); + }); + + it('refuses unknown software and bad redirect URIs', function (array $body, int $status, string $error) { + [$code, $response] = oauth_register($body); + + expect($code)->toBe($status) + ->and($response['error'])->toBe($error) + ->and($response['error_description'])->toBeString() + ->and(OAuthClient::whereNotNull('software_id')->count())->toBe(0); + })->with([ + 'unknown software' => [['software_id' => 'someone-else', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]], 400, 'invalid_client_metadata'], + 'no software' => [['redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]], 400, 'invalid_client_metadata'], + 'no redirect' => [['software_id' => 'fleetbase-woocommerce'], 400, 'invalid_redirect_uri'], + 'two redirects' => [['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT, 'https://b.example.com/cb']], 400, 'invalid_redirect_uri'], + 'plain http' => [['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => ['http://shop.example.com/cb']], 400, 'invalid_redirect_uri'], + 'custom scheme' => [['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => ['myapp://callback']], 400, 'invalid_redirect_uri'], + 'fragment' => [['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => ['https://shop.example.com/cb#x']], 400, 'invalid_redirect_uri'], + 'redirect not a string' => [['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [['nested']]], 400, 'invalid_redirect_uri'], + ]); + + it('is turned off by configuration, and discovery stops advertising it', function () { + expect(oauth_test_server()->discovery()['registration_endpoint'])->toBe('https://api.fleetbase.test/oauth/register'); + + config(['oauth-server.registration.enabled' => false]); + + expect(oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]])) + ->toBe([404, ['error' => 'registration_disabled', 'error_description' => 'Client registration is turned off on this server.']]) + ->and(oauth_test_server()->discovery())->not->toHaveKey('registration_endpoint'); + }); + + it('refuses a site whose registered client an admin disabled', function () { + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + OAuthClient::find($body['client_id'])->forceFill(['disabled_at' => now()])->save(); + + [$status, $again] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + + expect($status)->toBe(400) + ->and($again['error'])->toBe('invalid_client_metadata') + ->and(OAuthClient::whereNotNull('software_id')->count())->toBe(1); + }); +}); + +describe('connecting a registered site', function () { + it('signs in with PKCE alone and only to the registered redirect', function () { + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + $client = OAuthClient::find($body['client_id']); + + [$status, $tokens] = oauth_registration_tokens($client); + + expect($status)->toBe(200) + ->and(explode(' ', $tokens['scope']))->toEqualCanonicalizing(['organization:read', 'api-key:create']) + ->and($tokens)->not->toHaveKey('id_token'); + + // Never redirected: the error is shown on Fleetbase, not sent to the unregistered site. + expect(oauth_flow_registration_error($client, 'https://attacker.example.com/cb'))->not->toEndWith(':redirect') + ->and(oauth_flow_registration_error($client, 'https://attacker.example.com/cb'))->not->toBe('accepted'); + }); + + it('creates one live API key per site and organization, and returns it again', function () { + oauth_registration_api_credentials(); + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + $client = OAuthClient::find($body['client_id']); + [, $tokens] = oauth_registration_tokens($client); + + [$status, $first] = oauth_registration_api_key($tokens['access_token'], ['Access-Console-Sandbox' => 'true']); + [$again, $second] = oauth_registration_api_key($tokens['access_token']); + + $credential = ApiCredential::where('oauth_client_id', $client->id)->first(); + + expect($status)->toBe(201) + ->and($first['api_key']['created'])->toBeTrue() + ->and($first['api_key']['name'])->toBe('Fleetbase for WooCommerce (shop.example.com)') + ->and($first['api_key']['key'])->toMatch('/^flb_live_[A-Za-z0-9]{32}$/') + ->and($again)->toBe(200) + ->and($second['api_key']['created'])->toBeFalse() + ->and($second['api_key']['key'])->toBe($first['api_key']['key']) + ->and($credential->company_uuid)->toBe('company-a') + ->and($credential->user_uuid)->toBe('user-owner') + ->and((bool) $credential->test_mode)->toBeFalse() + ->and(ApiCredential::query()->count())->toBe(1); + }); + + it('never returns a key the app did not create, even with the same name', function () { + oauth_registration_api_credentials(); + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + $client = OAuthClient::find($body['client_id']); + Capsule::connection('mysql')->table('api_credentials')->insert([ + 'uuid' => 'manual-key', 'company_uuid' => 'company-a', 'name' => $client->name, 'key' => 'flb_live_handmade', 'test_mode' => 0, + ]); + [, $tokens] = oauth_registration_tokens($client); + + [$status, $minted] = oauth_registration_api_key($tokens['access_token']); + + expect($status)->toBe(201) + ->and($minted['api_key']['key'])->not->toBe('flb_live_handmade') + ->and(ApiCredential::query()->count())->toBe(2); + }); + + it('needs the api-key:create scope', function () { + oauth_registration_api_credentials(); + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + $client = OAuthClient::find($body['client_id']); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['redirect_uri' => OAUTH_REGISTRATION_REDIRECT, 'scope' => 'organization:read', 'nonce' => null]); + [, $tokens] = oauth_test_exchange($client, null, $code, $verifier, ['redirect_uri' => OAUTH_REGISTRATION_REDIRECT]); + + [$status, $error] = oauth_registration_api_key($tokens['access_token']); + + expect($status)->toBe(403) + ->and($error['error'])->toBe('insufficient_scope') + ->and(ApiCredential::query()->count())->toBe(0); + }); +}); + +describe('revoking a site', function () { + it('expires its key once no admin\'s grant for that organization is left', function () { + oauth_registration_api_credentials(); + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + $client = OAuthClient::find($body['client_id']); + [, $owner] = oauth_registration_tokens($client, 'user-owner'); + [, $admin] = oauth_registration_tokens($client, 'user-admin'); + oauth_registration_api_key($owner['access_token']); + + $ownerGrant = OAuthGrant::where('client_id', $client->id)->where('user_uuid', 'user-owner')->first(); + $adminGrant = OAuthGrant::where('client_id', $client->id)->where('user_uuid', 'user-admin')->first(); + + $expiresAt = fn () => Capsule::connection('mysql')->table('api_credentials')->value('expires_at'); + + $ownerGrant->revoke(); + expect($expiresAt())->toBeNull() + ->and(ApiCredential::query()->count())->toBe(1); + + $adminGrant->revoke(); + // Expired keys drop out of ApiCredential queries (Expirable), so the key is unusable. + expect($expiresAt())->not->toBeNull() + ->and(ApiCredential::query()->count())->toBe(0); + }); + + it('expires keys when an admin disables the app, and a fresh key is minted after reconnecting', function () { + oauth_registration_api_credentials(); + Container::getInstance()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), Container::getInstance())); + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + $client = OAuthClient::find($body['client_id']); + [, $tokens] = oauth_registration_tokens($client); + [, $first] = oauth_registration_api_key($tokens['access_token']); + + $admin = Fleetbase\Models\User::where('uuid', 'user-owner')->first(); + $request = Fleetbase\Http\Requests\AdminRequest::create('/int/v1/oauth-server/apps/' . $client->id . '/disable', 'POST'); + $request->setUserResolver(fn () => $admin); + (new OAuthAppController())->disable($request, $client->id); + + Carbon::setTestNow(now()->addMinute()); + $client->refresh()->forceFill(['disabled_at' => null])->save(); + [, $tokens] = oauth_registration_tokens($client); + [$status, $second] = oauth_registration_api_key($tokens['access_token']); + + expect($status)->toBe(201) + ->and($second['api_key']['key'])->not->toBe($first['api_key']['key']); + }); + + it('leaves keys alone on a database without api_credentials.oauth_client_id', function () { + [, $body] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + $client = OAuthClient::find($body['client_id']); + oauth_registration_tokens($client); + Capsule::connection('mysql')->getSchemaBuilder()->drop('api_credentials'); + + OAuthGrant::first()->revoke(); + OAuthGrant::expireMintedKeysFor([]); + + expect(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); +}); + +describe('pruning', function () { + it('deletes registered clients that never got a grant after a day, and keeps the rest', function () { + Carbon::setTestNow('2026-10-27 12:00:00'); + [, $unused] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => ['https://unused.example.com/cb']]); + [, $used] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => [OAUTH_REGISTRATION_REDIRECT]]); + [$official] = oauth_test_client(); + oauth_registration_tokens(OAuthClient::find($used['client_id'])); + + Carbon::setTestNow('2026-10-28 13:00:00'); + [, $recent] = oauth_register(['software_id' => 'fleetbase-woocommerce', 'redirect_uris' => ['https://recent.example.com/cb']]); + + $command = new OAuthServerPrune(); + $command->setLaravel(Container::getInstance()); + $output = new BufferedOutput(); + $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); + $command->setInput(new ArrayInput([], $command->getDefinition())); + $code = $command->handle(); + + expect($code)->toBe(0) + ->and($output->fetch())->toContain('and 1 unused registered clients.') + ->and(OAuthClient::find($unused['client_id']))->toBeNull() + ->and(OAuthClient::find($used['client_id']))->not->toBeNull() + ->and(OAuthClient::find($recent['client_id']))->not->toBeNull() + ->and(OAuthClient::find($official->id))->not->toBeNull(); + }); +}); + +/** + * The error code an authorization request gets when it names a redirect the client doesn't have. + */ +function oauth_flow_registration_error(OAuthClient $client, string $redirect): string +{ + try { + oauth_test_server()->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), [ + 'redirect_uri' => $redirect, + 'scope' => 'organization:read api-key:create', + ])); + } catch (Fleetbase\Auth\OAuthServer\Exceptions\AuthorizeError $e) { + return $e->error . ($e->canRedirect() ? ':redirect' : ''); + } + + return 'accepted'; +} + +describe('installations of one shared app', function () { + it('gives each installation its own key, named after it, and returns it again', function () { + oauth_registration_api_credentials(); + [$client, $token] = oauth_registration_shared_client_tokens(); + + [$statusA, $a] = oauth_registration_api_key($token, [], 'POST', ['installation' => 'alpha.myshopify.com']); + [$statusB, $b] = oauth_registration_api_key($token, [], 'POST', ['installation' => ' beta.myshopify.com ']); + [$again, $a2] = oauth_registration_api_key($token, [], 'POST', ['installation' => 'alpha.myshopify.com']); + [$wide, $plain] = oauth_registration_api_key($token); + + expect([$statusA, $statusB, $again, $wide])->toBe([201, 201, 200, 201]) + ->and($a['api_key']['name'])->toBe('Fleetbase for Shopify (alpha.myshopify.com)') + ->and($a['api_key']['installation'])->toBe('alpha.myshopify.com') + ->and($b['api_key']['installation'])->toBe('beta.myshopify.com') + ->and($b['api_key']['key'])->not->toBe($a['api_key']['key']) + ->and($a2['api_key']['key'])->toBe($a['api_key']['key']) + ->and($plain['api_key']['name'])->toBe('Fleetbase for Shopify') + ->and($plain['api_key']['installation'])->toBeNull() + ->and($plain['api_key']['key'])->not->toBeIn([$a['api_key']['key'], $b['api_key']['key']]) + ->and(ApiCredential::query()->where('oauth_client_id', $client->id)->count())->toBe(3); + }); + + it('deletes one installation\'s key, leaving the others and the grant', function () { + oauth_registration_api_credentials(); + [$client, $token] = oauth_registration_shared_client_tokens(); + [, $a] = oauth_registration_api_key($token, [], 'POST', ['installation' => 'alpha.myshopify.com']); + [, $b] = oauth_registration_api_key($token, [], 'POST', ['installation' => 'beta.myshopify.com']); + [, $wide] = oauth_registration_api_key($token); + + [$status, $body] = oauth_registration_api_key($token, [], 'DELETE', ['installation' => 'alpha.myshopify.com']); + [$repeat] = oauth_registration_api_key($token, [], 'DELETE', ['installation' => 'alpha.myshopify.com']); + $live = ApiCredential::query()->pluck('key')->all(); + + expect($status)->toBe(204) + ->and($body)->toBeNull() + ->and($repeat)->toBe(204) + ->and($live)->not->toContain($a['api_key']['key']) + ->and($live)->toContain($b['api_key']['key'], $wide['api_key']['key']) + ->and(OAuthGrant::active()->where('client_id', $client->id)->count())->toBe(1); + + // Without an installation only the app-wide key goes; installation keys stay. + oauth_registration_api_key($token, [], 'DELETE'); + expect(ApiCredential::query()->pluck('key')->all())->toBe([$b['api_key']['key']]); + + // A new key for the deleted installation is minted on request. + [$minted, $fresh] = oauth_registration_api_key($token, [], 'POST', ['installation' => 'alpha.myshopify.com']); + expect($minted)->toBe(201)->and($fresh['api_key']['key'])->not->toBe($a['api_key']['key']); + }); + + it('expires every installation\'s key when the grant is revoked', function () { + oauth_registration_api_credentials(); + [$client, $token] = oauth_registration_shared_client_tokens(); + oauth_registration_api_key($token, [], 'POST', ['installation' => 'alpha.myshopify.com']); + oauth_registration_api_key($token, [], 'POST', ['installation' => 'beta.myshopify.com']); + oauth_registration_api_key($token); + + OAuthGrant::where('client_id', $client->id)->first()->revoke(); + + expect(ApiCredential::query()->count())->toBe(0) + ->and(Capsule::connection('mysql')->table('api_credentials')->whereNull('expires_at')->count())->toBe(0); + }); + + it('refuses an installation that is not a short plain string', function (mixed $installation) { + oauth_registration_api_credentials(); + [, $token] = oauth_registration_shared_client_tokens(); + + foreach (['POST', 'DELETE'] as $method) { + [$status, $body] = oauth_registration_api_key($token, [], $method, ['installation' => $installation]); + expect($status)->toBe(400)->and($body['error'])->toBe('invalid_request'); + } + expect(ApiCredential::query()->count())->toBe(0); + })->with([ + 'too long' => [str_repeat('a', 191)], + 'array' => [['shop']], + 'control chars' => ["shop\nevil"], + ]); + + it('treats a blank installation as none', function () { + oauth_registration_api_credentials(); + [, $token] = oauth_registration_shared_client_tokens(); + + [, $blank] = oauth_registration_api_key($token, [], 'POST', ['installation' => ' ']); + [, $none] = oauth_registration_api_key($token); + + expect($blank['api_key']['installation'])->toBeNull() + ->and($none['api_key']['key'])->toBe($blank['api_key']['key']); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php new file mode 100644 index 00000000..5fa9e3c7 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php @@ -0,0 +1,309 @@ + 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $keys = oauth_test_keys(); + + $container = bind_test_container([ + 'app.key' => 'base64:' . base64_encode(str_repeat('k', 32)), + 'app.url' => 'https://api.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'oauth-server.private_key' => $keys['private'], + 'oauth-server.public_key' => $keys['public'], + 'oauth-server.ttl' => ['auth_code' => 'PT10M', 'access_token' => 'PT1H', 'refresh_token' => 'P30D', 'id_token' => 'PT1H'], + ]); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('mysql')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->string('email')->nullable(); + $table->string('name')->nullable(); + $table->string('timezone')->nullable(); + $table->string('avatar_uuid')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('name'); + $table->string('logo_uuid')->nullable(); + $table->string('currency')->nullable(); + $table->string('country')->nullable(); + $table->string('timezone')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('company_users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('files', function ($table) { + $table->string('uuid')->primary(); + $table->string('url')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + (require __DIR__ . '/../../../../migrations/2026_10_25_000001_create_oauth_server_tables.php')->up(); + + $db = $capsule->getConnection('mysql'); + $now = '2026-10-01 00:00:00'; + $db->table('users')->insert([ + ['uuid' => 'user-owner', 'public_id' => 'user_owner', 'type' => 'user', 'status' => 'active', 'email' => 'owner@example.com', 'name' => 'Olive Owner', 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'user-admin', 'public_id' => 'user_admin', 'type' => 'user', 'status' => 'active', 'email' => 'admin@example.com', 'name' => 'Ada Admin', 'email_verified_at' => null, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'user-member', 'public_id' => 'user_member', 'type' => 'user', 'status' => 'active', 'email' => 'member@example.com', 'name' => 'Mo Member', 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now], + ]); + $db->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_a', 'owner_uuid' => 'user-owner', 'name' => 'Acme Deliveries', 'currency' => 'USD', 'country' => 'US', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'company-b', 'public_id' => 'company_b', 'owner_uuid' => 'user-member', 'name' => 'Bravo Freight', 'currency' => 'SGD', 'country' => 'SG', 'created_at' => $now, 'updated_at' => $now], + ]); + $db->table('company_users')->insert([ + ['uuid' => 'cu-owner-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-owner', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-admin-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-admin', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-member-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-member', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-member-b', 'company_uuid' => 'company-b', 'user_uuid' => 'user-member', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ]); + + // Only user-admin holds company A's Administrator role. + OAuthOrganizations::$administratorCheck = fn ($membership) => $membership->uuid === 'cu-admin-a'; + + OAuthScopes::flush(); + OAuthScopes::registerDefaults(); + OAuthScopes::register('storefront:read', ['description' => "See {organization}'s storefronts", 'organization' => true]); + OAuthScopes::register('storefront:review-access', ['description' => 'Turn App Review sign-in on or off', 'organization' => true, 'review_access_types' => ['storefront-store']]); + + return $capsule; +} + +/** + * Real spatie/permission roles for company memberships, keyed by company_users.uuid. + * + * @param array $roles + */ +function oauth_test_roles(array $roles): void +{ + config([ + 'auth.defaults.guard' => 'sanctum', + 'permission.models.permission' => Fleetbase\Models\Permission::class, + 'permission.models.role' => Fleetbase\Models\Role::class, + 'permission.table_names.roles' => 'roles', + 'permission.table_names.permissions' => 'permissions', + 'permission.table_names.model_has_permissions' => 'model_has_permissions', + 'permission.table_names.model_has_roles' => 'model_has_roles', + 'permission.table_names.role_has_permissions' => 'role_has_permissions', + 'permission.column_names.model_morph_key' => 'model_uuid', + ]); + Container::getInstance()->instance(Spatie\Permission\PermissionRegistrar::class, new class { + public string $pivotRole = 'role_id'; + public string $pivotPermission = 'permission_id'; + public bool $teams = false; + public string $teamsKey = 'team_id'; + + public function getRoleClass(): string + { + return Fleetbase\Models\Role::class; + } + + public function getPermissionClass(): string + { + return Fleetbase\Models\Permission::class; + } + + public function getPermissions(array $params = [], bool $onlyOne = false): Illuminate\Database\Eloquent\Collection + { + return new Illuminate\Database\Eloquent\Collection(); + } + }); + + $schema = Capsule::connection('mysql')->getSchemaBuilder(); + $schema->create('roles', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->default('sanctum'); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('model_has_roles', function ($table) { + $table->string('role_id'); + $table->string('model_type'); + $table->string('model_uuid'); + }); + $schema->create('permissions', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->default('sanctum'); + $table->timestamps(); + }); + $schema->create('model_has_permissions', function ($table) { + $table->string('permission_id'); + $table->string('model_type'); + $table->string('model_uuid'); + }); + $schema->create('role_has_permissions', function ($table) { + $table->string('permission_id'); + $table->string('role_id'); + }); + + $db = Capsule::connection('mysql'); + foreach (array_values(array_unique($roles)) as $index => $name) { + $db->table('roles')->insert(['id' => 'role-' . $index, 'name' => $name, 'guard_name' => 'sanctum']); + } + $ids = $db->table('roles')->pluck('id', 'name'); + foreach ($roles as $membership => $name) { + $db->table('model_has_roles')->insert(['role_id' => $ids[$name], 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => $membership]); + } +} + +/** + * Register an app. Returns [client, plain secret or null]. + */ +function oauth_test_client(array $attributes = [], bool $confidential = true): array +{ + $client = new OAuthClient(array_merge([ + 'name' => 'AppHelm', + 'redirect_uris' => [OAUTH_TEST_REDIRECT], + 'allowed_scopes' => ['openid', 'profile', 'email', 'organization:read', 'storefront:read'], + 'official' => true, + ], $attributes)); + $secret = $confidential ? $client->issueSecret() : null; + $client->save(); + + return [$client, $secret]; +} + +function oauth_test_server(): OAuthServer +{ + return new OAuthServer(); +} + +function oauth_test_verifier(): string +{ + return OAuthKeys::base64url(random_bytes(48)); +} + +function oauth_test_challenge(string $verifier): string +{ + return OAuthKeys::base64url(hash('sha256', $verifier, true)); +} + +function oauth_test_params(OAuthClient $client, string $verifier, array $overrides = []): array +{ + return array_filter(array_merge([ + 'response_type' => 'code', + 'client_id' => $client->id, + 'redirect_uri' => OAUTH_TEST_REDIRECT, + 'scope' => 'openid profile email organization:read', + 'state' => 'state-123', + 'nonce' => 'nonce-abc', + 'code_challenge' => oauth_test_challenge($verifier), + 'code_challenge_method' => 'S256', + ], $overrides), fn ($value) => $value !== null); +} + +/** + * Run the consent step and return the code from the redirect. + */ +function oauth_test_code(OAuthClient $client, string $verifier, array $overrides = [], string $userUuid = 'user-owner', ?string $companyUuid = 'company-a'): string +{ + $server = oauth_test_server(); + $authorization = $server->validateAuthorizationRequest(oauth_test_params($client, $verifier, $overrides)); + $user = Fleetbase\Models\User::where('uuid', $userUuid)->first(); + $company = $companyUuid ? Fleetbase\Models\Company::where('uuid', $companyUuid)->first() : null; + $result = $server->approve($authorization, $user, $company, new DateTimeImmutable('-1 minute')); + + parse_str((string) parse_url($result['redirect'], PHP_URL_QUERY), $query); + + return $query['code']; +} + +/** + * POST to the token endpoint. Returns [status, decoded body]. + */ +function oauth_test_token(array $params): array +{ + $request = Request::create('/oauth/token', 'POST', $params); + $response = oauth_test_server()->respondToTokenRequest($request); + + return [$response->getStatusCode(), json_decode($response->getContent(), true)]; +} + +function oauth_test_exchange(OAuthClient $client, ?string $secret, string $code, string $verifier, array $overrides = []): array +{ + return oauth_test_token(array_filter(array_merge([ + 'grant_type' => 'authorization_code', + 'client_id' => $client->id, + 'client_secret' => $secret, + 'code' => $code, + 'redirect_uri' => OAUTH_TEST_REDIRECT, + 'code_verifier' => $verifier, + ], $overrides), fn ($value) => $value !== null)); +} + +function oauth_test_bearer(string $accessToken, string $uri = '/oauth/v1/organization'): Request +{ + return Request::create($uri, 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $accessToken]); +} + +function oauth_test_teardown(): void +{ + OAuthOrganizations::$administratorCheck = null; + OAuthScopes::flush(); + EloquentModel::clearBootedModels(); + Container::setInstance(new FleetbaseTestContainer()); + Facade::clearResolvedInstances(); +} diff --git a/tests/Unit/Http/FileControllerTest.php b/tests/Unit/Http/FileControllerTest.php index 0f0d91a5..109fa911 100644 --- a/tests/Unit/Http/FileControllerTest.php +++ b/tests/Unit/Http/FileControllerTest.php @@ -429,11 +429,11 @@ function public_file_controller_failing_upload_request(array $input = []): Uploa return UploadFileRequest::create('/v1/files', 'POST', $input, [], ['file' => $file]); } -function file_controller_upload_request(array $input = [], string $contents = 'uploaded body'): UploadFileRequest +function file_controller_upload_request(array $input = [], string $contents = 'uploaded body', string $originalName = 'manifest.txt'): UploadFileRequest { $path = tempnam(sys_get_temp_dir(), 'fleetbase-upload-'); file_put_contents($path, $contents); - $file = new UploadedFile($path, 'manifest.txt', 'text/plain', null, true); + $file = new UploadedFile($path, $originalName, 'text/plain', null, true); return UploadFileRequest::create('/int/v1/files', 'POST', $input, [], ['file' => $file]); } @@ -510,6 +510,19 @@ function file_controller_download_request(array $query = []): DownloadFileReques ->and(Storage::disk('testing')->get('uploads/documents/manifest.txt'))->toBe('plain text body'); }); +test('file controller stores uploads under the extension of their detected type, never a script extension', function () { + $capsule = file_controller_fixtures(); + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgBzq7oQAAAAASUVORK5CYII='); + + $response = file_controller()->upload(file_controller_upload_request(['path' => 'uploads'], $png . '', 'shell.php')); + $record = $capsule->getConnection('mysql')->table('files')->first(); + + expect($response->getStatusCode())->toBe(200) + ->and($record->path)->toStartWith('uploads/') + ->and($record->path)->toEndWith('.png') + ->and(Storage::disk('testing')->get($record->path))->toBe($png . ''); +}); + test('file controller uploads multipart files with generated storage path and subject metadata', function () { $capsule = file_controller_fixtures(); diff --git a/tests/Unit/Http/OnboardControllerTest.php b/tests/Unit/Http/OnboardControllerTest.php index ed1cac69..d6a4c65f 100644 --- a/tests/Unit/Http/OnboardControllerTest.php +++ b/tests/Unit/Http/OnboardControllerTest.php @@ -388,9 +388,74 @@ function onboard_create_account_request(array $input = []): OnboardRequest $existing = onboard_controller()->shouldOnboard(); expect($empty->getStatusCode())->toBe(200) - ->and($empty->getData(true))->toBe(['should_onboard' => true]) + ->and($empty->getData(true))->toBe(['should_onboard' => true, 'hosted_routing' => true]) ->and($existing->getStatusCode())->toBe(200) - ->and($existing->getData(true))->toBe(['should_onboard' => false]); + ->and($existing->getData(true))->toBe(['should_onboard' => false, 'hosted_routing' => false]); +}); + +test('onboard controller offers hosted routing only when it is turned on and no own server is set', function () { + $capsule = onboard_controller_database(); + + config(['fleetbase.hosted_routing.auto_register' => false]); + $disabled = onboard_controller()->shouldOnboard()->getData(true); + + config(['fleetbase.hosted_routing.auto_register' => true]); + Fleetbase\Models\Setting::configure('valhalla', ['api_host' => 'https://valhalla.internal.acme.test']); + $ownServer = onboard_controller()->shouldOnboard()->getData(true); + config(['fleetbase.hosted_routing' => null]); + + expect($disabled)->toBe(['should_onboard' => true, 'hosted_routing' => false]) + ->and($ownServer)->toBe(['should_onboard' => true, 'hosted_routing' => false]); +}); + +function onboard_controller_bus(): object +{ + $bus = new class { + public array $dispatched = []; + + public function dispatchAfterResponse($job): void + { + $this->dispatched[] = $job; + } + }; + app()->instance(Illuminate\Bus\Dispatcher::class, $bus); + + return $bus; +} + +test('the first admin who keeps hosted routing on is registered after the response', function () { + onboard_controller_database(); + $bus = onboard_controller_bus(); + + onboard_controller()->createAccount(onboard_create_account_request(['hosted_routing' => true])); + + expect($bus->dispatched)->toHaveCount(1) + ->and($bus->dispatched[0])->toBeInstanceOf(Fleetbase\Jobs\RegisterHostedRouting::class) + ->and($bus->dispatched[0]->email)->toBe('grace@example.test') + ->and($bus->dispatched[0]->domain)->toBe('http://localhost'); + app()->forgetInstance(Illuminate\Bus\Dispatcher::class); +}); + +test('hosted routing is not registered without consent, for later accounts or when turned off', function () { + $capsule = onboard_controller_database(); + $bus = onboard_controller_bus(); + + // Unchecked (or an older console that never sends the field). + onboard_controller()->createAccount(onboard_create_account_request(['hosted_routing' => false])); + // A later account is never the first admin. + onboard_controller()->createAccount(onboard_create_account_request(['email' => 'ada@example.test', 'phone' => '+15555550125', 'hosted_routing' => true])); + + expect($bus->dispatched)->toBeEmpty(); + + // Turned off on a fresh install. + $capsule = onboard_controller_database(); + $bus = onboard_controller_bus(); + config(['fleetbase.hosted_routing.auto_register' => false]); + onboard_controller()->createAccount(onboard_create_account_request(['hosted_routing' => true])); + config(['fleetbase.hosted_routing' => null]); + + expect($bus->dispatched)->toBeEmpty(); + app()->forgetInstance(Illuminate\Bus\Dispatcher::class); }); test('onboard controller creates the first account as an administrator and completes setup', function () { diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index dd7538d3..7c1df368 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -26,7 +26,7 @@ function setting_controller_external_probe_fixtures(array $config = []): void 'twilio.twilio.connections.twilio' => [ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'broadcasting.connections.socketcluster.auth_key' => null, 'broadcasting.connections.socketcluster.options' => [ @@ -71,7 +71,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ ->and(config('twilio.twilio.connections.twilio'))->toBe([ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ]); }); @@ -82,7 +82,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'override-sid', 'token' => 'override-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -107,7 +107,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'rest-sid', 'token' => 'rest-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -135,7 +135,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'error-sid', 'token' => 'error-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -158,7 +158,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'warning-sid', 'token' => 'warning-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -325,12 +325,12 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getData(true)['message'])->toBe('recorded') ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse('the credentials under test do not outlive the request'); }); @@ -341,13 +341,13 @@ function setting_controller_twilio_facade_is_cached(): bool $response = (new SettingController())->testSmsProviderConfig(setting_controller_external_probe_request([ 'provider' => 'twilio', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'config' => ['sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ])); expect($response->getData(true))->toMatchArray(['status' => 'error', 'message' => 'recorded']) ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse(); }); @@ -361,11 +361,11 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect(app('twilio'))->toBe($twilio) - ->and($twilio->messages)->toBe([['+15555550123', 'This is a Twilio test from Fleetbase']]); + ->and($twilio->messages)->toBe([['+15555551023', 'This is a Twilio test from Fleetbase']]); }); test('a twilio manager bound but not yet built is built from the config just applied', function () { diff --git a/tests/Unit/Http/SettingControllerServicesTest.php b/tests/Unit/Http/SettingControllerServicesTest.php index 48205a2a..4391253d 100644 --- a/tests/Unit/Http/SettingControllerServicesTest.php +++ b/tests/Unit/Http/SettingControllerServicesTest.php @@ -103,7 +103,7 @@ function setting_controller_services_fixtures(): void 'services.twilio' => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'sentry.dsn' => 'https://sentry.example.test/1', 'sms.default_provider' => SmsService::PROVIDER_TWILIO, @@ -153,7 +153,7 @@ function setting_controller_services_request(array $input = []): AdminRequest 'googleMapsLocale' => 'mn', 'twilioSid' => 'twilio-sid', 'twilioToken' => 'twilio-token', - 'twilioFrom' => '+15555550100', + 'twilioFrom' => '+15555551000', 'sentryDsn' => 'https://sentry.example.test/1', ]) ->and($payload['sms']['defaultProvider'])->toBe(SmsService::PROVIDER_TWILIO) @@ -172,7 +172,7 @@ function setting_controller_services_request(array $input = []): AdminRequest SmsService::PROVIDER_TWILIO => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], ]) ->and($payload['sms']['available'][SmsService::PROVIDER_TWILIO])->toBe([ @@ -207,7 +207,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => 'unsupported_gateway', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'api_key' => 'unsupported-key', @@ -237,7 +237,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => SmsService::PROVIDER_TWILIO, - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'sid' => 'temporary-twilio-sid', diff --git a/tests/Unit/Listeners/ResourceLifecycleWebhookListenerTest.php b/tests/Unit/Listeners/ResourceLifecycleWebhookListenerTest.php index a0a1804a..0e152423 100644 --- a/tests/Unit/Listeners/ResourceLifecycleWebhookListenerTest.php +++ b/tests/Unit/Listeners/ResourceLifecycleWebhookListenerTest.php @@ -379,6 +379,7 @@ function resource_lifecycle_webhook_listener_database(): array $table->string('mode')->nullable(); $table->text('events')->nullable(); $table->string('status')->nullable(); + $table->text('secret')->nullable(); $table->timestamps(); $table->softDeletes(); }); diff --git a/tests/Unit/Models/FileModelTest.php b/tests/Unit/Models/FileModelTest.php index 3e416eac..2ea45c95 100644 --- a/tests/Unit/Models/FileModelTest.php +++ b/tests/Unit/Models/FileModelTest.php @@ -569,3 +569,28 @@ function bind_file_model_filesystem(array $config = []): FileModelFilesystemFake ->and($gif)->toEndWith('.gif') ->and($png)->not->toBe($jpg); }); + +it('stores uploads under an extension that matches their contents and never a script extension', function () { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgBzq7oQAAAAASUVORK5CYII='); + $upload = function (string $contents, string $name): UploadedFile { + $path = tempnam(sys_get_temp_dir(), 'fleetbase-upload-'); + file_put_contents($path, $contents); + + return new UploadedFile($path, $name, 'application/octet-stream', null, true); + }; + + expect(File::storageExtensionForUpload($upload($png, 'photo.PNG')))->toBe('png') + ->and(File::storageExtensionForUpload($upload($png, 'photo.jpg')))->toBe('png') + ->and(File::storageExtensionForUpload($upload($png . '', 'shell.php')))->toBe('png') + ->and(File::storageExtensionForUpload($upload($png, 'shell.phtml')))->toBe('png') + ->and(File::storageExtensionForUpload($upload($png, 'no-extension')))->toBe('png') + ->and(File::storageExtensionForUpload($upload("id,name\n1,Alpha\n2,Beta\n", 'orders.csv')))->toBe('csv') + ->and(File::storageExtensionForUpload($upload('{"a":1}', 'config.json')))->toBe('json') + ->and(File::storageExtensionForUpload($upload('plain notes', 'notes.phar')))->toBe('txt') + ->and(File::storageExtensionForUpload($upload(str_repeat("\x00\x9f\x01\xfe", 16), 'blob.php5')))->toBe('bin'); + + $request = Request::create('/int/v1/files/upload', 'POST', [], [], ['file' => $upload($png . '', 'shell.php')]); + + expect(File::randomFileNameFromRequest($request))->toEndWith('.png') + ->and(File::randomFileNameFromRequest($request, 'file', 'webp'))->toEndWith('.webp'); +}); diff --git a/tests/Unit/Models/VerificationCodeGuardTest.php b/tests/Unit/Models/VerificationCodeGuardTest.php index aa0dfe5d..60890d9d 100644 --- a/tests/Unit/Models/VerificationCodeGuardTest.php +++ b/tests/Unit/Models/VerificationCodeGuardTest.php @@ -213,6 +213,19 @@ function verification_code_guard_subject(string $phone): User ->and($twilio->messages)->toBe([]); }); +it('never texts a number reserved for fiction, such as an App Review number, and still saves the code', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + $repeat = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + + // Nothing was reserved, so the guard's cooldown never refuses the repeat either. + expect($code->exists)->toBeTrue() + ->and($repeat->exists)->toBeTrue() + ->and($code->meta)->not->toHaveKey('trace') + ->and($twilio->messages)->toBe([]); +}); + it('leaves the code row untouched when the guard is disabled', function () { $twilio = verification_code_guard_database(['enabled' => false]); diff --git a/tests/Unit/Providers/CoreProviderContractsTest.php b/tests/Unit/Providers/CoreProviderContractsTest.php index 68d485a3..30726a96 100644 --- a/tests/Unit/Providers/CoreProviderContractsTest.php +++ b/tests/Unit/Providers/CoreProviderContractsTest.php @@ -939,7 +939,7 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF ['loadMigrationsFrom', dirname(__DIR__, 3) . '/src/Providers/../../migrations'], ['loadViewsFrom', dirname(__DIR__, 3) . '/src/Providers/../../views', 'fleetbase'] ) - ->and($provider->schedule->commands)->toHaveCount(9) + ->and($provider->schedule->commands)->toHaveCount(10) ->and(array_map(fn ($event) => $event->name, $provider->schedule->commands))->toBe([ 'cache:prune-stale-tags', 'model:prune', @@ -948,13 +948,15 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF 'purge:activity-logs --force --no-interaction --days 2 --keep-backups=30', 'purge:scheduled-task-logs --force --no-interaction --days 1 --keep-backups=30', 'model:prune', + 'oauth-server:prune', 'telemetry:ping', 'sandbox:sync', ]) ->and($provider->schedule->commands[1]->parameters)->toBe(['--model' => Spatie\ScheduleMonitor\Models\MonitoredScheduledTaskLogItem::class]) ->and($provider->schedule->commands[6]->parameters)->toBe(['--model' => Fleetbase\Models\OAuthState::class]) ->and($provider->schedule->commands[6]->methods)->toBe([['hourly']]) - ->and($provider->schedule->commands[8]->methods)->toBe([['hourly'], ['name', 'sandbox-sync'], ['withoutOverlapping']]) + ->and($provider->schedule->commands[7]->methods)->toBe([['daily']]) + ->and($provider->schedule->commands[9]->methods)->toBe([['hourly'], ['name', 'sandbox-sync'], ['withoutOverlapping']]) ->and($provider->schedule->jobs)->toHaveCount(1) ->and($provider->schedule->jobs[0]->name)->toBe(Fleetbase\Jobs\MaterializeSchedulesJob::class) ->and($provider->schedule->jobs[0]->methods)->toBe([['dailyAt', '01:00'], ['name', 'materialize-schedules'], ['withoutOverlapping']]) diff --git a/tests/Unit/SmsServiceGuardTest.php b/tests/Unit/SmsServiceGuardTest.php index 5ad59e67..6b1d77fc 100644 --- a/tests/Unit/SmsServiceGuardTest.php +++ b/tests/Unit/SmsServiceGuardTest.php @@ -142,3 +142,13 @@ protected function sendViaTwilio(string $to, string $text, array $options = []): expect($failed)->not->toBeNull() ->and($failed[2]['error'])->toBe('invalid sender'); }); + +test('sms service never texts a number reserved for fiction, such as an App Review number', function () { + sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $result = $service->send('+1 202 555 0143', 'hello', ['purpose' => 'storefront_login']); + + expect($result)->toMatchArray(['success' => true, 'skipped' => true]) + ->and($service->dispatches)->toBe([]); +}); diff --git a/tests/Unit/Support/AppReviewTest.php b/tests/Unit/Support/AppReviewTest.php new file mode 100644 index 00000000..3f9a20fd --- /dev/null +++ b/tests/Unit/Support/AppReviewTest.php @@ -0,0 +1,441 @@ +pluck('name')->intersect($this->granted)->isEmpty(); + } +} + +class AppReviewTestNetwork extends FleetbaseModel +{ + protected $table = 'app_review_test_networks'; + protected $guarded = []; +} + +/** + * An in-memory database with two stores in two companies, a network and the review access + * table, and the store and network types registered the way Storefront registers them. + */ +function app_review_database(): Capsule +{ + EloquentModel::clearBootedModels(); + + $connection = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $container = bind_test_container([ + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'testing', + ]); + $container->instance('hash', new class { + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + }); + $container->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'testing'); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('testing'); + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('testing')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('testing')->getSchemaBuilder(); + foreach (['app_review_test_stores', 'app_review_test_networks'] as $table) { + $schema->create($table, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + // Company lives on the core connection. + $capsule->getConnection('mysql')->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + // The migration, as it runs on the core connection. + (require __DIR__ . '/../../../migrations/2026_10_24_000000_create_review_accesses_table.php')->up(); + + $db = $capsule->getConnection('testing'); + $db->table('app_review_test_stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_a', 'company_uuid' => 'company-a'], + ['uuid' => 'store-b', 'public_id' => 'store_b', 'company_uuid' => 'company-b'], + ]); + $db->table('app_review_test_networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_a', 'company_uuid' => 'company-a'], + ]); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_a', 'name' => 'Corner Co'], + ['uuid' => 'company-b', 'public_id' => 'company_b', 'name' => 'Night Co'], + ]); + + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + AppReview::register('test-store', ['model' => AppReviewTestStore::class, 'phone' => '+1 (202) 555-0143', 'name' => 'Storefront']); + AppReview::register('test-network', ['model' => AppReviewTestNetwork::class, 'phone' => '+12025550143', 'name' => 'Storefront']); + AppReview::register('test-navigator', ['model' => Company::class, 'phone' => '+12025550144', 'name' => 'Navigator']); + + return $capsule; +} + +function app_review_store(string $uuid): AppReviewTestStore +{ + return AppReviewTestStore::where('uuid', $uuid)->first(); +} + +function app_review_console_request(string $company, array $input = [], string $method = 'PUT'): Request +{ + $request = Request::create('/int/v1/review-access', $method, $input); + $request->setLaravelSession(new SessionStore('app-review-test', new ArraySessionHandler(120))); + $request->session()->put('company', $company); + + return $request; +} + +afterEach(function () { + foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded', 'test-resolved', 'test-unreachable'] as $type) { + AppReview::forget($type); + } + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('review numbers must come from the range reserved for fiction', function () { + bind_test_container(); + + expect(AppReview::isFictionalPhone('+12025550143'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+1 (415) 555-0199'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+12025550200'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+12025551234'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+97699112233'))->toBeFalse() + ->and(AppReview::isFictionalPhone(null))->toBeFalse() + ->and(AppReview::formatPhone('+12025550143'))->toBe('+1 202 555 0143') + ->and(fn () => AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025551234']))->toThrow(InvalidArgumentException::class) + ->and(fn () => AppReview::register('test-guarded', ['model' => 'Missing\\Model', 'phone' => '+12025550143']))->toThrow(InvalidArgumentException::class); + + // libphonenumber accepts the numbers the apps register. + $util = libphonenumber\PhoneNumberUtil::getInstance(); + foreach (['+12025550143', '+12025550144'] as $phone) { + expect($util->isValidNumber($util->parse($phone, null)))->toBeTrue(); + } +}); + +test('turning access on creates a code that only works for that subject', function () { + app_review_database(); + $storeA = app_review_store('store-a'); + $storeB = app_review_store('store-b'); + + expect(AppReview::typeOf($storeA))->toBe('test-store') + ->and(AppReview::isEnabledFor($storeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+12025550143', '000000'))->toBeFalse(); + + [$access, $codeA] = AppReview::update($storeA, true); + do { + [, $codeB] = AppReview::update($storeB, true, true); + } while ($codeB === $codeA); + + expect($codeA)->toMatch('/^\d{6}$/') + ->and($access->getRawOriginal('code'))->not->toBe($codeA) + ->and($access->toArray())->not->toHaveKey('code') + ->and(AppReview::isEnabledFor($storeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+1 202 555 0143', $codeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+12025550143', $codeB))->toBeFalse() + ->and(AppReview::acceptsCode($storeB, '+12025550143', $codeA))->toBeFalse() + // Only the type's own number signs in. + ->and(AppReview::acceptsCode($storeA, '+12025550144', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+97699112233', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode(null, '+12025550143', $codeA))->toBeFalse(); +}); + +test('turning access off keeps the code, and regenerating replaces it', function () { + app_review_database(); + $store = app_review_store('store-a'); + + [, $code] = AppReview::update($store, true); + [, $none] = AppReview::update($store, false); + expect($none)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeFalse() + ->and(AppReview::status($store))->toMatchArray(['enabled' => false, 'has_code' => true]); + + [, $restored] = AppReview::update($store, true); + expect($restored)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue(); + + [, $new] = AppReview::update($store, null, true); + expect($new)->toMatch('/^\d{6}$/') + ->and(AppReview::acceptsCode($store, '+12025550143', $new))->toBeTrue() + ->and($new === $code || !AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue() + ->and(ReviewAccess::count())->toBe(1); +}); + +test('a company can be the subject, with its own review number', function () { + app_review_database(); + $company = Company::where('uuid', 'company-a')->first(); + + [, $code] = AppReview::update($company, true); + + expect(AppReview::typeOf($company))->toBe('test-navigator') + ->and(AppReview::status($company))->toMatchArray(['type' => 'test-navigator', 'phone' => '+12025550144', 'phone_display' => '+1 202 555 0144', 'enabled' => true]) + ->and(AppReview::acceptsCode($company, '+12025550144', $code))->toBeTrue() + ->and(AppReview::acceptsCode($company, '+12025550143', $code))->toBeFalse() + ->and(ReviewAccess::first()->company_uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_a', 'company-a')?->uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_b', 'company-a'))->toBeNull(); +}); + +test('a subject is found by uuid or public id in the signed-in company only', function () { + app_review_database(); + + expect(AppReview::resolveSubject('test-store', 'store-a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_b', 'company-a'))->toBeNull() + ->and(AppReview::resolveSubject('test-store', 'store_a', null))->toBeNull() + ->and(AppReview::resolveSubject('unknown-type', 'store_a', 'company-a'))->toBeNull(); +}); + +test('the instance-wide review accounts only accept their code for listed identities', function () { + bind_test_container([ + 'app.review.code' => '135790', + 'app.review.accounts' => [' Reviewer@Example.Test ', '+12025550100'], + ]); + + expect(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'reviewer@example.test'))->toBeTrue() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'other@example.test'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135790'))->toBeTrue() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135791'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', 'other@example.test', '135790'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', null))->toBeFalse(); + + config(['app.review.code' => null]); + expect(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', ''))->toBeFalse() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', '+12025550100'))->toBeFalse(); +}); + +test('the Console endpoint shows the code once and never again', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + $controller = new ReviewAccessController(); + + $initial = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + expect($initial)->toBe([ + 'type' => 'test-store', + 'enabled' => false, + 'phone' => '+12025550143', + 'phone_display' => '+1 202 555 0143', + 'has_code' => false, + 'code_generated_at' => null, + 'enabled_at' => null, + ]); + + $on = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + $again = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + $saved = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + + expect($on['enabled'])->toBeTrue() + ->and($on['code'])->toMatch('/^\d{6}$/') + ->and($again)->not->toHaveKey('code') + ->and($saved)->not->toHaveKey('code') + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', $on['code']))->toBeTrue(); + + // Another company's store, and a type nobody registered, are not found. + expect($controller->update('test-store', 'store_b', app_review_console_request('company-a', ['enabled' => true]))->getStatusCode())->toBe(404) + ->and($controller->show('nothing', 'store_a', app_review_console_request('company-a', [], 'GET'))->getStatusCode())->toBe(404) + ->and($controller->update('test-network', 'network_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']['type'])->toBe('test-network'); +}); + +test('a registered permission is required to change access', function () { + app_review_database(); + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront manage-app-review store']); + + // Nobody signed in, so nobody holds the permission: refused before anything changes. + $response = (new ReviewAccessController())->update('test-guarded', 'store_a', app_review_console_request('company-a', ['enabled' => true])); + + expect($response->getStatusCode())->toBe(401) + ->and(ReviewAccess::count())->toBe(0); +})->skip(fn () => !method_exists(Fleetbase\Support\Auth::class, 'getUserFromSession')); + +test('the registry answers for unknown types, any type\'s number and numbers it cannot format', function () { + app_review_database(); + + expect(array_keys(AppReview::types()))->toContain('test-store', 'test-network', 'test-navigator') + ->and(AppReview::typeOf(new AppReviewTestUnregistered()))->toBeNull() + ->and(AppReview::typeOf(null))->toBeNull() + ->and(AppReview::definition('nothing'))->toBeNull() + ->and(AppReview::phoneFor('nothing'))->toBeNull() + // Without a type, any registered number is a review number. + ->and(AppReview::isReviewPhone('+1 202 555 0144'))->toBeTrue() + ->and(AppReview::isReviewPhone('+12025550199'))->toBeFalse() + ->and(AppReview::isReviewPhone(''))->toBeFalse() + ->and(AppReview::isReviewPhone(null, 'test-store'))->toBeFalse() + // Only North American numbers are spaced out. + ->and(AppReview::formatPhone('+97699112233'))->toBe('+97699112233') + ->and(AppReview::formatPhone(null))->toBeNull(); +}); + +test('a type can bring its own resolver for the subject a Console user may manage', function () { + app_review_database(); + $calls = []; + AppReview::register('test-resolved', [ + 'model' => AppReviewTestUnregistered::class, + 'phone' => '+12025550150', + 'resolve' => function (string $id, ?string $companyUuid) use (&$calls) { + $calls[] = [$id, $companyUuid]; + + return AppReviewTestUnregistered::where('public_id', $id)->first(); + }, + ]); + + expect(AppReview::resolveSubject('test-resolved', 'store_b', 'company-a')?->uuid)->toBe('store-b') + ->and($calls)->toBe([['store_b', 'company-a']]); +}); + +test('access is absent, not an error, for an unsaved subject, an unregistered one, or before the migration', function () { + $capsule = app_review_database(); + + expect(AppReview::accessFor(new AppReviewTestStore()))->toBeNull() + ->and(AppReview::accessFor(new AppReviewTestUnregistered(['uuid' => 'store-a'])))->toBeNull() + ->and(fn () => AppReview::update(new AppReviewTestUnregistered(['uuid' => 'store-a']), true))->toThrow(InvalidArgumentException::class); + + $capsule->getConnection('testing')->getSchemaBuilder()->drop('review_accesses'); + + expect(AppReview::accessFor(app_review_store('store-a')))->toBeNull() + ->and(AppReview::isEnabledFor(app_review_store('store-a')))->toBeFalse() + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', '123456'))->toBeFalse(); +}); + +test('a subject whose table cannot be read is matched by uuid only', function () { + app_review_database(); + $hasColumn = new ReflectionMethod(AppReview::class, 'hasColumn'); + + expect($hasColumn->invoke(null, AppReviewTestUnreachable::class, 'public_id'))->toBeFalse() + ->and($hasColumn->invoke(null, AppReviewTestStore::class, 'public_id'))->toBeTrue(); +}); + +test('the Console endpoint lets administrators and permission holders in, and nobody else', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + config(['permission.table_names.permissions' => 'permissions', 'auth.defaults.guard' => 'sanctum', 'auth.guards.sanctum' => ['driver' => 'sanctum']]); + Capsule::connection('mysql')->getSchemaBuilder()->create('permissions', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->nullable(); + $table->timestamps(); + }); + Capsule::connection('mysql')->table('permissions')->insert([ + ['id' => 'permission-manage', 'name' => 'storefront manage-app-review store', 'guard_name' => 'sanctum'], + ['id' => 'permission-wildcard', 'name' => 'storefront * store', 'guard_name' => 'sanctum'], + ['id' => 'permission-view', 'name' => 'storefront view store', 'guard_name' => 'sanctum'], + ]); + AppReview::forget('test-store'); + AppReview::register('test-guarded', [ + 'model' => AppReviewTestStore::class, + 'phone' => '+12025550143', + 'permission' => 'storefront manage-app-review store', + 'view_permission' => 'storefront view store', + ]); + + $as = function (?AppReviewTestUser $user, array $input = [], string $method = 'PUT') { + $request = app_review_console_request('company-a', $input, $method); + $request->setUserResolver(fn () => $user); + + return $request; + }; + $user = function (string $type, array $granted = []) { + $user = new AppReviewTestUser(); + $user->type = $type; + $user->granted = $granted; + + return $user; + }; + $controller = new ReviewAccessController(); + + $nobody = $controller->show('test-guarded', 'store_a', $as(null, [], 'GET')); + $viewer = $controller->show('test-guarded', 'store_a', $as($user('user', ['storefront view store']), [], 'GET')); + $readOnly = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront view store']), ['enabled' => true])); + $manager = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront manage-app-review store']), ['enabled' => true])); + $wildcard = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront * store']), ['enabled' => false])); + $admin = $controller->update('test-guarded', 'store_a', $as($user('admin'), ['regenerate_code' => true])); + + expect($nobody->getStatusCode())->toBe(401) + ->and($viewer->getData(true)['review_access']['type'])->toBe('test-guarded') + ->and($readOnly->getStatusCode())->toBe(401) + ->and($manager->getData(true)['review_access'])->toHaveKey('code') + ->and($wildcard->getData(true)['review_access']['enabled'])->toBeFalse() + ->and($admin->getData(true)['review_access'])->toHaveKey('code'); + + // A permission the install never seeded lets nobody but administrators in. + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront unseeded store']); + expect($controller->update('test-guarded', 'store_a', $as($user('user', ['storefront unseeded store']), ['enabled' => true]))->getStatusCode())->toBe(401); +}); diff --git a/tests/Unit/Support/HostedRoutingTest.php b/tests/Unit/Support/HostedRoutingTest.php new file mode 100644 index 00000000..d345e384 --- /dev/null +++ b/tests/Unit/Support/HostedRoutingTest.php @@ -0,0 +1,366 @@ +warnings[] = (string) $message; + } +} + +function hosted_routing_fixtures(array $config = []): Capsule +{ + EloquentModel::clearBootedModels(); + + $connection = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $container = bind_test_container(array_merge([ + 'app.url' => 'https://fleet.acme.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'fleetbase.version' => '0.7.70', + 'fleetbase.hosted_routing.auto_register' => true, + 'fleetbase.hosted_routing.registration_url' => HOSTED_ROUTING_URL, + 'fleetbase.hosted_routing.api_host' => 'https://valhalla.routing.fleetbase.io', + 'fleetbase.hosted_routing.domain' => 'routing.fleetbase.io', + 'fleetbase.hosted_routing.timeout' => 10, + ], $config)); + $container->instance(HttpFactory::class, new HttpFactory()); + $container->instance('log', new HostedRoutingTestLogger()); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + EloquentModel::unsetEventDispatcher(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('email')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + + return $capsule; +} + +function hosted_routing_admin(Capsule $capsule, string $email = 'first@acme.test', string $createdAt = '2026-10-01 00:00:00'): void +{ + $capsule->getConnection('mysql')->table('users')->insert(['uuid' => 'user-' . md5($email), 'email' => $email, 'type' => 'admin', 'created_at' => $createdAt]); +} + +function hosted_routing_registered(array $overrides = []): array +{ + return array_merge([ + 'status' => 'registered', + 'key' => 'fbrl_' . str_repeat('k', 28) . 'ab12', + 'license' => ['uuid' => 'license-1', 'owner' => 'instance'], + 'plan' => 'routing_unverified', + 'email_verified' => false, + 'verification_sent' => true, + 'claimed' => false, + 'claim_code' => 'code-1', + 'claim_url' => 'https://console.fleetbase.io/~/routing?claim=code-1', + 'api_host' => 'https://valhalla.routing.fleetbase.io', + ], $overrides); +} + +beforeEach(function () { + Carbon::setTestNow('2026-10-24 10:00:00'); +}); + +afterEach(function () { + Carbon::setTestNow(); + config(['fleetbase.hosted_routing' => null, 'app.url' => null]); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('the install identity is created once at runtime and kept in the database', function () { + hosted_routing_fixtures(); + + $first = InstanceIdentity::get(); + $second = InstanceIdentity::get(); + + expect($first['id'])->toMatch('/^[0-9a-f-]{36}$/') + ->and($first['secret'])->toHaveLength(64) + ->and($first['created_at'])->toBe('2026-10-24T10:00:00+00:00') + ->and($second)->toBe($first) + ->and(InstanceIdentity::id())->toBe($first['id']) + ->and(InstanceIdentity::secret())->toBe($first['secret']) + ->and(Setting::where('key', 'system.instance')->count())->toBe(1); +}); + +test('a malformed install identity row is replaced', function () { + hosted_routing_fixtures(); + Setting::configure('system.instance', ['id' => 'only-an-id']); + + $identity = InstanceIdentity::get(); + + expect($identity['secret'])->toHaveLength(64) + ->and(Setting::lookup('system.instance'))->toBe($identity); +}); + +test('hosted hosts are routing.fleetbase.io and its subdomains only', function () { + hosted_routing_fixtures(); + + expect(HostedRouting::isHostedHost('https://valhalla.routing.fleetbase.io'))->toBeTrue() + ->and(HostedRouting::isHostedHost('https://routing.fleetbase.io/valhalla'))->toBeTrue() + ->and(HostedRouting::isHostedHost('https://notrouting.fleetbase.io'))->toBeFalse() + ->and(HostedRouting::isHostedHost('https://valhalla.example.com'))->toBeFalse() + ->and(HostedRouting::isHostedHost(null))->toBeFalse(); +}); + +test('registering stores the key in the valhalla setting and the registration for the panel', function () { + $capsule = hosted_routing_fixtures(); + hosted_routing_admin($capsule, 'later@acme.test', '2026-10-05 00:00:00'); + hosted_routing_admin($capsule); + Setting::configure('valhalla', ['api_host' => 'https://valhalla1.openstreetmap.de', 'extra' => 'kept']); + Http::fake([HOSTED_ROUTING_URL => Http::response(hosted_routing_registered(), 201)]); + + expect(HostedRouting::isAvailable())->toBeTrue() + ->and(HostedRouting::registration())->toBeNull(); + + $result = HostedRouting::register(); + + expect($result)->toBe([ + 'status' => 'registered', + 'message' => 'Fleetbase Hosted Routing is on.', + 'plan' => 'routing_unverified', + 'email' => 'first@acme.test', + 'email_verified' => false, + 'claim_url' => 'https://console.fleetbase.io/~/routing?claim=code-1', + ]) + ->and(Setting::lookup('valhalla'))->toBe([ + 'api_host' => 'https://valhalla.routing.fleetbase.io', + 'extra' => 'kept', + 'api_key' => hosted_routing_registered()['key'], + ]) + ->and(HostedRouting::registration())->toBe([ + 'license' => 'license-1', + 'key_hint' => 'ab12', + 'plan' => 'routing_unverified', + 'email' => 'first@acme.test', + 'email_verified' => false, + 'claimed' => false, + 'claim_url' => 'https://console.fleetbase.io/~/routing?claim=code-1', + 'api_host' => 'https://valhalla.routing.fleetbase.io', + 'registered_at' => '2026-10-24T10:00:00+00:00', + ]) + ->and(json_encode(HostedRouting::registration()))->not->toContain(hosted_routing_registered()['key']); + + $identity = InstanceIdentity::get(); + Http::assertSent(fn (HttpRequest $request) => $request->url() === HOSTED_ROUTING_URL + && $request->data() === [ + 'product' => 'routing', + 'instance_id' => $identity['id'], + 'instance_secret' => $identity['secret'], + 'email' => 'first@acme.test', + 'domain' => 'https://fleet.acme.test', + 'version' => '0.7.70', + ]); + + // Idempotent: the install already holds its key. + expect(HostedRouting::register())->toMatchArray(['status' => 'skipped', 'reason' => 'already_registered']); + Http::assertSentCount(1); +}); + +test('forcing registration again replaces the key and falls back to the configured host', function () { + hosted_routing_fixtures(); + Setting::configure('valhalla', ['api_host' => 'https://valhalla.routing.fleetbase.io', 'api_key' => 'fbrl_old']); + Setting::configure('system.hosted-routing', ['key_hint' => '_old']); + Http::fake([HOSTED_ROUTING_URL => Http::response(hosted_routing_registered(['api_host' => null, 'key' => 'fbrl_newkey9999', 'claimed' => true, 'claim_url' => null]))]); + + $result = HostedRouting::register('ops@acme.test', 'https://ops.acme.test', true); + + expect($result)->toMatchArray(['status' => 'registered', 'email' => 'ops@acme.test', 'claim_url' => null]) + ->and(Setting::lookup('valhalla'))->toBe(['api_host' => 'https://valhalla.routing.fleetbase.io', 'api_key' => 'fbrl_newkey9999']) + ->and(HostedRouting::registration())->toMatchArray(['key_hint' => '9999', 'claimed' => true]); + Http::assertSent(fn (HttpRequest $request) => $request['domain'] === 'https://ops.acme.test'); +}); + +test('registration is skipped when turned off or when the admin runs their own server', function () { + hosted_routing_fixtures(['fleetbase.hosted_routing.auto_register' => false]); + Http::fake(); + + expect(HostedRouting::isEnabled())->toBeFalse() + ->and(HostedRouting::isAvailable())->toBeFalse() + ->and(HostedRouting::register('a@acme.test'))->toBe([ + 'status' => 'skipped', + 'reason' => 'disabled', + 'message' => 'Hosted routing registration is turned off (ROUTING_AUTO_REGISTER=false).', + ]); + + config(['fleetbase.hosted_routing.auto_register' => true]); + Setting::configure('valhalla', ['api_host' => 'https://valhalla.internal.acme.test', 'api_key' => 'own']); + + expect(HostedRouting::isAvailable())->toBeFalse() + ->and(HostedRouting::register('a@acme.test'))->toMatchArray(['status' => 'skipped', 'reason' => 'own_server']); + + // A stored value that is not an object counts as nothing configured. + Setting::configure('valhalla', 'garbage'); + expect(HostedRouting::isAvailable())->toBeTrue(); + + Http::assertNothingSent(); +}); + +test('registration needs an administrator email', function () { + hosted_routing_fixtures(); + Http::fake(); + + expect(HostedRouting::register())->toMatchArray(['status' => 'failed', 'reason' => 'no_email']); + Http::assertNothingSent(); +}); + +test('an unreachable or refusing Fleetbase Cloud fails without touching the settings', function () { + hosted_routing_fixtures(); + $log = app('log'); + + Http::fake([HOSTED_ROUTING_URL => fn () => throw new ConnectionException('timed out')]); + expect(HostedRouting::register('a@acme.test'))->toMatchArray(['status' => 'failed', 'reason' => 'unreachable']); + + Http::swap(new HttpFactory()); + Http::fake([HOSTED_ROUTING_URL => Http::response(['reason' => 'limit_reached', 'error' => 'Too many installs are registered for this email.'], 429)]); + expect(HostedRouting::register('a@acme.test'))->toBe([ + 'status' => 'failed', + 'reason' => 'limit_reached', + 'message' => 'Too many installs are registered for this email.', + ]); + + Http::swap(new HttpFactory()); + Http::fake([HOSTED_ROUTING_URL => Http::response('bad gateway', 502)]); + expect(HostedRouting::register('a@acme.test'))->toBe([ + 'status' => 'failed', + 'reason' => 'refused', + 'message' => 'Registration was refused (HTTP 502).', + ]); + + Http::swap(new HttpFactory()); + Http::fake([HOSTED_ROUTING_URL => Http::response(['status' => 'registered'], 201)]); + expect(HostedRouting::register('a@acme.test'))->toMatchArray(['status' => 'failed', 'reason' => 'refused']); + + expect(Setting::lookup('valhalla'))->toBeNull() + ->and(HostedRouting::registration())->toBeNull() + ->and($log->warnings)->toBe([ + 'Hosted routing registration failed: timed out', + 'Hosted routing registration refused: 429 limit_reached', + 'Hosted routing registration refused: 502 no reason', + 'Hosted routing registration refused: 201 no reason', + ]); +}); + +test('the onboarding job runs after the response through the bus', function () { + hosted_routing_fixtures(); + $bus = new class { + public array $dispatched = []; + + public function dispatchAfterResponse($job): void + { + $this->dispatched[] = $job; + } + }; + app()->instance(Illuminate\Bus\Dispatcher::class, $bus); + + RegisterHostedRouting::afterResponse('grace@acme.test', 'https://api.acme.test'); + + expect($bus->dispatched)->toHaveCount(1) + ->and($bus->dispatched[0])->toBeInstanceOf(RegisterHostedRouting::class) + ->and($bus->dispatched[0]->email)->toBe('grace@acme.test') + ->and($bus->dispatched[0]->domain)->toBe('https://api.acme.test'); + app()->forgetInstance(Illuminate\Bus\Dispatcher::class); +}); + +test('the onboarding job registers with the admin email and the install URL', function () { + hosted_routing_fixtures(); + Http::fake([HOSTED_ROUTING_URL => Http::response(hosted_routing_registered(), 201)]); + + (new RegisterHostedRouting('grace@acme.test', 'https://api.acme.test'))->handle(); + + expect(Setting::lookup('valhalla')['api_key'])->toBe(hosted_routing_registered()['key']); + Http::assertSent(fn (HttpRequest $request) => $request['email'] === 'grace@acme.test' && $request['domain'] === 'https://api.acme.test'); +}); + +function hosted_routing_command(array $input = []): CommandTester +{ + $command = new RegisterHostedRoutingCommand(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->execute($input); + + return $tester; +} + +test('routing:register explains what is sent and how to confirm and upgrade', function () { + hosted_routing_fixtures(); + Http::fake([HOSTED_ROUTING_URL => Http::response(hosted_routing_registered(), 201)]); + + $tester = hosted_routing_command(['--email' => 'grace@acme.test', '--domain' => 'https://fleet.acme.test']); + $display = $tester->getDisplay(); + + expect($tester->getStatusCode())->toBe(0) + ->and($display)->toContain('Singapore (DigitalOcean sgp1)') + ->and($display)->toContain('the admin email and this install\'s URL') + ->and($display)->toContain('Fleetbase Hosted Routing is on. Plan: routing_unverified.') + ->and($display)->toContain('Confirm the email sent to grace@acme.test to raise the limit from 500 to 2,500 routes a day.') + ->and($display)->toContain('https://console.fleetbase.io/~/routing?claim=code-1') + ->and($display)->not->toContain('fbrl_'); +}); + +test('routing:register succeeds quietly for a verified or claimed key and on skips', function () { + hosted_routing_fixtures(); + Http::fake([HOSTED_ROUTING_URL => Http::response(hosted_routing_registered(['email_verified' => true, 'claim_url' => null, 'plan' => null]))]); + + $registered = hosted_routing_command(['--email' => 'grace@acme.test', '--force' => true]); + $skipped = hosted_routing_command(['--email' => 'grace@acme.test']); + + expect($registered->getStatusCode())->toBe(0) + ->and($registered->getDisplay())->toContain('Plan: unknown.') + ->and($registered->getDisplay())->not->toContain('Confirm the email') + ->and($registered->getDisplay())->not->toContain('Upgrade later') + ->and($skipped->getStatusCode())->toBe(0) + ->and($skipped->getDisplay())->toContain('already has a Fleetbase Hosted Routing key'); +}); + +test('routing:register fails with the reason when registration fails', function () { + hosted_routing_fixtures(); + Http::fake(); + + $tester = hosted_routing_command(); + + expect($tester->getStatusCode())->toBe(1) + ->and($tester->getDisplay())->toContain('No administrator email to register with. Pass --email.'); +}); diff --git a/tests/Unit/Support/TwoFactorAuthTest.php b/tests/Unit/Support/TwoFactorAuthTest.php index cb64d7a7..77a5163e 100644 --- a/tests/Unit/Support/TwoFactorAuthTest.php +++ b/tests/Unit/Support/TwoFactorAuthTest.php @@ -217,7 +217,7 @@ function two_factor_auth_fixtures(): array 'uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => $company->uuid, 'email' => 'user@example.com', - 'phone' => '+15555550100', + 'phone' => '+15555551000', 'username' => 'test-user', 'name' => 'Test User', ]); diff --git a/tests/Unit/Webhook/EndpointWebhookSigningTest.php b/tests/Unit/Webhook/EndpointWebhookSigningTest.php new file mode 100644 index 00000000..c21f4a93 --- /dev/null +++ b/tests/Unit/Webhook/EndpointWebhookSigningTest.php @@ -0,0 +1,376 @@ +encrypt($value, false); + } + + public function decryptString($payload) + { + return $this->decrypt($payload, false); + } + + public function getKey() + { + return 'test'; + } + + public function getAllKeys() + { + return ['test']; + } + + public function getPreviousKeys() + { + return []; + } + } + + class EndpointSigningCacheFake + { + 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 rememberForever(string $key, Closure $callback): mixed + { + return $this->values[$key] ??= $callback(); + } + + 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 flush(): bool + { + $this->values = []; + + return true; + } + } + + /** + * Per-endpoint webhook signing secrets: the X-Fleetbase-Signature header, secret storage on the + * endpoint model, the API-key webhook endpoint routes, and the console reveal/rotate actions. + */ + function endpoint_signing_boot(array $config = []): Capsule + { + $container = bind_test_container(array_merge([ + 'database.default' => 'mysql', + 'database.connections.mysql' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'fleetbase.connection.db' => 'mysql', + 'activitylog.enabled' => false, + 'api.cache.enabled' => false, + 'api.events' => ['order.created', 'order.updated'], + 'api.versions' => ['2023-01-01', '2026-10-01'], + 'webhook-server.signer' => DefaultSigner::class, + 'webhook-server.signature_header_name' => 'Signature', + 'webhook-server.headers' => ['Content-Type' => 'application/json'], + 'webhook-server.http_verb' => 'post', + 'webhook-server.timeout_in_seconds' => 5, + ], $config)); + $container->instance(Illuminate\Contracts\Config\Repository::class, $container->make('config')); + $container->instance('encrypter', new EndpointSigningEncrypterFake()); + $container->instance('cache', new EndpointSigningCacheFake()); + $container->instance('responsecache', new class { + public function clear(array $tags = []): bool + { + return true; + } + }); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection(config('database.connections.mysql'), 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + // Model event listeners register once per boot; re-boot so they attach to this dispatcher + Illuminate\Database\Eloquent\Model::clearBootedModels(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + + $capsule->getConnection('mysql')->getSchemaBuilder()->create('webhook_endpoints', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('_key')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('updated_by_uuid')->nullable(); + $table->string('api_credential_uuid')->nullable(); + $table->string('url')->nullable(); + $table->string('mode')->nullable(); + $table->string('version')->nullable(); + $table->text('secret')->nullable(); + $table->string('description')->nullable(); + $table->text('events')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + return $capsule; + } + + function endpoint_signing_job(WebhookCall $call): CallWebhookJob + { + $prepare = new ReflectionMethod($call, 'prepareForDispatch'); + $prepare->setAccessible(true); + $prepare->invoke($call); + + $property = new ReflectionProperty($call, 'callWebhookJob'); + $property->setAccessible(true); + + return $property->getValue($call); + } + + afterEach(function () { + Facade::clearResolvedInstances(); + }); + + test('endpoint signatures cover timestamp and body and reject tampering, age and malformed headers', function () { + $header = EndpointSignature::header('{"a":1}', 'whsec_test', 1700000000); + + expect($header)->toBe('t=1700000000,v1=' . hash_hmac('sha256', '1700000000.{"a":1}', 'whsec_test')) + ->and(EndpointSignature::verify('{"a":1}', $header, 'whsec_test', 300, 1700000100))->toBeTrue() + ->and(EndpointSignature::verify('{"a":2}', $header, 'whsec_test', 300, 1700000100))->toBeFalse() + ->and(EndpointSignature::verify('{"a":1}', $header, 'other', 300, 1700000100))->toBeFalse() + ->and(EndpointSignature::verify('{"a":1}', $header, 'whsec_test', 60, 1700000100))->toBeFalse() + ->and(EndpointSignature::verify('{"a":1}', null, 'whsec_test'))->toBeFalse() + ->and(EndpointSignature::verify('{"a":1}', $header, ''))->toBeFalse() + ->and(EndpointSignature::verify('{"a":1}', 'v1=abc', 'whsec_test'))->toBeFalse() + ->and(EndpointSignature::verify('{"a":1}', 't=abc,v1=x', 'whsec_test'))->toBeFalse(); + + // Several v1 values (during a secret rotation) pass if any matches; a bare key is ignored + $rotating = 't=1700000000,v1=deadbeef,junk,' . explode(',', $header)[1]; + expect(EndpointSignature::verify('{"a":1}', $rotating, 'whsec_test', 300, 1700000000))->toBeTrue() + ->and(strlen(explode('=', EndpointSignature::header('x', 's'))[1]))->toBeGreaterThan(9); + }); + + test('webhook calls add the endpoint signature for body requests only', function () { + endpoint_signing_boot(); + + $job = endpoint_signing_job(WebhookCall::create()->url('https://hooks.example.test/x')->payload(['event' => 'order.updated'])->useSecret('legacy')->useEndpointSecret('whsec_abc')); + expect($job->headers)->toHaveKeys(['Signature', EndpointSignature::HEADER]) + ->and(EndpointSignature::verify(json_encode(['event' => 'order.updated']), $job->headers[EndpointSignature::HEADER], 'whsec_abc'))->toBeTrue(); + + $get = endpoint_signing_job(WebhookCall::create()->url('https://hooks.example.test/x')->payload(['a' => 1])->useHttpVerb('GET')->useSecret('legacy')->useEndpointSecret('whsec_abc')); + $none = endpoint_signing_job(WebhookCall::create()->url('https://hooks.example.test/x')->payload(['a' => 1])->useSecret('legacy')->useEndpointSecret('')); + expect($get->headers)->not->toHaveKey(EndpointSignature::HEADER) + ->and($none->headers)->not->toHaveKey(EndpointSignature::HEADER); + }); + + test('a legacy header configured under the same name is not overwritten', function () { + endpoint_signing_boot(['webhook-server.signature_header_name' => 'X-Fleetbase-Signature']); + + $job = endpoint_signing_job(WebhookCall::create()->url('https://hooks.example.test/x')->payload(['a' => 1])->useSecret('legacy')->useEndpointSecret('whsec_abc')); + expect($job->headers['X-Fleetbase-Signature'])->toBe(hash_hmac('sha256', json_encode(['a' => 1]), 'legacy')); + }); + + test('endpoints get an encrypted hidden secret on creation, on first use, and on rotation', function () { + $capsule = endpoint_signing_boot(); + + $endpoint = WebhookEndpoint::create(['company_uuid' => 'company-1', 'url' => 'https://hooks.example.test/a', 'mode' => 'live', 'status' => 'enabled']); + $raw = $capsule->getConnection('mysql')->table('webhook_endpoints')->where('uuid', $endpoint->uuid)->value('secret'); + + expect($endpoint->secret)->toStartWith(WebhookEndpoint::SECRET_PREFIX) + ->and(strlen($endpoint->secret))->toBe(strlen(WebhookEndpoint::SECRET_PREFIX) + 48) + ->and($raw)->not->toBe($endpoint->secret) + ->and($raw)->not->toContain('whsec_') + ->and($endpoint->getHidden())->toContain('secret') + ->and($endpoint->getActivitylogOptions()->logExceptAttributes)->toContain('secret') + ->and($endpoint->signingSecret())->toBe($endpoint->secret); + + // An endpoint created before secrets existed gets one the first time it is needed + $capsule->getConnection('mysql')->table('webhook_endpoints')->insert(['uuid' => 'legacy-1', 'company_uuid' => 'company-1', 'url' => 'https://hooks.example.test/b', 'mode' => 'live']); + $legacy = WebhookEndpoint::where('uuid', 'legacy-1')->first(); + $first = $legacy->signingSecret(); + expect($first)->toStartWith('whsec_') + ->and(WebhookEndpoint::where('uuid', 'legacy-1')->first()->secret)->toBe($first); + + $rotated = $legacy->rotateSecret(); + expect($rotated)->not->toBe($first) + ->and(WebhookEndpoint::where('uuid', 'legacy-1')->first()->secret)->toBe($rotated); + }); + + test('the lifecycle listener falls back to legacy signing when a secret cannot be loaded', function () { + endpoint_signing_boot(); + $listener = new class extends SendResourceLifecycleWebhook { + public function secretFor(WebhookEndpoint $webhook): ?string + { + return $this->endpointSecret($webhook); + } + }; + $broken = new class extends WebhookEndpoint { + public function signingSecret(): string + { + throw new RuntimeException('decrypt failed'); + } + }; + $working = new WebhookEndpoint(); + $working->setRawAttributes(['uuid' => 'w'], true); + $working->secret = 'whsec_known'; + + expect($listener->secretFor($broken))->toBeNull() + ->and($listener->secretFor($working))->toBe('whsec_known'); + }); + + function endpoint_api_request(string $method, array $input = []): WebhookEndpointRequest + { + return WebhookEndpointRequest::create('/v1/webhook-endpoints', $method, $input); + } + + test('api keys manage only their company and environment endpoints and see secrets once', function () { + endpoint_signing_boot(); + session(['company' => 'company-1', 'api_credential' => 'cred-1', 'api_environment' => 'test']); + $controller = new ApiWebhookEndpointController(); + + $created = $controller->create(endpoint_api_request('POST', ['url' => 'https://hooks.example.test/shop', 'events' => ['order.updated'], 'description' => 'Shopify'])); + $body = $created->getData(true); + expect($body['id'] ?? null)->toBeString(); + expect($created->getStatusCode())->toBe(201) + ->and($body['secret'])->toStartWith('whsec_') + ->and($body['mode'])->toBe('test') + ->and($body['version'])->toBe('2026-10-01') + ->and($body['events'])->toBe(['order.updated']) + ->and(WebhookEndpoint::where('uuid', $body['id'])->value('api_credential_uuid'))->toBe('cred-1'); + + // Another company's endpoint and a live endpoint are invisible to this test key + WebhookEndpoint::create(['company_uuid' => 'company-2', 'url' => 'https://hooks.example.test/other', 'mode' => 'test']); + $live = WebhookEndpoint::create(['company_uuid' => 'company-1', 'url' => 'https://hooks.example.test/live', 'mode' => 'live']); + + $list = $controller->query(new Illuminate\Http\Request())->getData(true); + expect($list)->toHaveCount(1) + ->and($list[0])->not->toHaveKey('secret') + ->and($controller->query(new Illuminate\Http\Request(['url' => 'https://nope.test']))->getData(true))->toBe([]) + ->and($controller->find($live->uuid)->getStatusCode())->toBe(404) + ->and($controller->find($body['id'])->getData(true))->not->toHaveKey('secret'); + + $updated = $controller->update($body['id'], endpoint_api_request('PUT', ['status' => 'disabled', 'events' => []]))->getData(true); + expect($updated['status'])->toBe('disabled') + ->and($updated['events'])->toBe([]) + ->and($controller->update($live->uuid, endpoint_api_request('PUT', ['status' => 'disabled']))->getStatusCode())->toBe(404); + + $rotated = $controller->rotateSecret($body['id'])->getData(true); + expect($rotated['secret'])->not->toBe($body['secret']) + ->and($controller->rotateSecret($live->uuid)->getStatusCode())->toBe(404); + + expect($controller->delete($live->uuid)->getStatusCode())->toBe(404) + ->and($controller->delete($body['id'])->getData(true))->toBe(['id' => $body['id'], 'deleted' => true]) + ->and(WebhookEndpoint::where('uuid', $body['id'])->first())->toBeNull(); + + // Live keys create live endpoints; no configured versions leaves the version empty + session(['api_environment' => 'live']); + config()->set('api.versions', []); + $liveBody = $controller->create(endpoint_api_request('POST', ['url' => 'https://hooks.example.test/live2']))->getData(true); + expect($liveBody['mode'])->toBe('live') + ->and($liveBody['version'])->toBeNull(); + }); + + test('the api webhook request validates urls, events and status', function () { + endpoint_signing_boot(); + session(['company' => 'company-1', 'api_credential' => 'cred-1']); + + $post = endpoint_api_request('POST')->rules(); + $put = endpoint_api_request('PUT')->rules(); + expect($post['url'][0])->toBe('required') + ->and($put['url'][0])->toBe('sometimes') + ->and($post['events.*'])->toBe(['string', 'in:order.created,order.updated']) + ->and($post['status'])->toBe(['sometimes', 'in:enabled,disabled']) + ->and(endpoint_api_request('POST')->authorize())->toBeTrue() + ->and(endpoint_api_request('POST')->messages())->toHaveKeys(['url.required', 'url.url', 'events.*.in']); + + config()->set('api.events', []); + expect(endpoint_api_request('POST')->rules()['events.*'])->toBe(['string', 'string']); + + session(['api_credential' => null]); + expect(endpoint_api_request('POST')->authorize())->toBeFalse(); + }); + + test('console users can reveal and rotate an endpoint secret in their company', function () { + endpoint_signing_boot(); + session(['company' => 'company-1']); + $endpoint = WebhookEndpoint::create(['company_uuid' => 'company-1', 'url' => 'https://hooks.example.test/c', 'mode' => 'live']); + WebhookEndpoint::create(['company_uuid' => 'company-2', 'url' => 'https://hooks.example.test/d', 'mode' => 'live', 'uuid' => 'foreign-1']); + $controller = (new ReflectionClass(InternalWebhookEndpointController::class))->newInstanceWithoutConstructor(); + + $revealed = $controller->revealSecret($endpoint->uuid)->getData(true); + $rotated = $controller->rotateSecret($endpoint->uuid)->getData(true); + expect($revealed['secret'])->toBe($endpoint->secret) + ->and($rotated['secret'])->not->toBe($revealed['secret']) + ->and($controller->revealSecret('foreign-1')->getStatusCode())->toBe(404) + ->and($controller->rotateSecret('missing')->getStatusCode())->toBe(404); + }); +}