diff --git a/RELEASE.md b/RELEASE.md index fbeec04d..eb57cdc0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,6 +13,8 @@ - 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 @@ -22,6 +24,6 @@ ## Upgrade Steps -- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column, the `review_accesses` table and the `oauth_*` provider tables (`oauth_clients`, `oauth_grants`, `oauth_auth_codes`, `oauth_access_tokens`, `oauth_refresh_tokens`). +- 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/config/oauth-server.php b/config/oauth-server.php index c27b42d9..54e5a4b1 100644 --- a/config/oauth-server.php +++ b/config/oauth-server.php @@ -47,4 +47,31 @@ * 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_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/OAuthScopes.php b/src/Auth/OAuthServer/OAuthScopes.php index 1081cb2d..56dca0b6 100644 --- a/src/Auth/OAuthServer/OAuthScopes.php +++ b/src/Auth/OAuthServer/OAuthScopes.php @@ -55,6 +55,10 @@ public static function registerDefaults(): void '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 diff --git a/src/Auth/OAuthServer/OAuthServer.php b/src/Auth/OAuthServer/OAuthServer.php index f5bdf991..8902d1a6 100644 --- a/src/Auth/OAuthServer/OAuthServer.php +++ b/src/Auth/OAuthServer/OAuthServer.php @@ -537,7 +537,9 @@ public function authenticate(Request $request, array $scopes = []): OAuthAccessT */ public function discovery(): array { - return [ + $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'), diff --git a/src/Console/Commands/OAuthServerPrune.php b/src/Console/Commands/OAuthServerPrune.php index c9f81662..fb351074 100644 --- a/src/Console/Commands/OAuthServerPrune.php +++ b/src/Console/Commands/OAuthServerPrune.php @@ -4,20 +4,24 @@ use Fleetbase\Models\OAuthAccessToken; use Fleetbase\Models\OAuthAuthCode; +use Fleetbase\Models\OAuthClient; use Fleetbase\Models\OAuthRefreshToken; use Illuminate\Console\Command; /** - * Deletes OAuth codes and tokens that expired more than a week ago. + * Deletes OAuth codes and tokens that expired more than a week ago, and self-registered clients + * that never got a grant. * * The week's grace keeps a replayed code or refresh token recognisable for a while, so a replay - * still revokes what it should instead of looking like an unknown token. + * still revokes what it should instead of looking like an unknown token. A client registered at + * POST /oauth/register registers again the next time its site connects, so one left unused for + * `oauth-server.registration.unused_client_hours` is safe to drop. */ class OAuthServerPrune extends Command { protected $signature = 'oauth-server:prune {--days=7 : Keep expired rows this many days}'; - protected $description = 'Delete expired OAuth authorization codes and tokens'; + protected $description = 'Delete expired OAuth authorization codes and tokens, and unused self-registered clients'; public function handle(): int { @@ -29,7 +33,12 @@ public function handle(): int ->whereNotIn('id', OAuthRefreshToken::select('access_token_id')) ->delete(); - $this->info("Deleted {$codes} codes, {$access} access tokens and {$refresh} refresh tokens."); + $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/Http/Controllers/Internal/v1/OAuthAppController.php b/src/Http/Controllers/Internal/v1/OAuthAppController.php index 02c060b0..8f3684f6 100644 --- a/src/Http/Controllers/Internal/v1/OAuthAppController.php +++ b/src/Http/Controllers/Internal/v1/OAuthAppController.php @@ -92,6 +92,7 @@ public function disable(AdminRequest $request, string $id): JsonResponse $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()]); } 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 index 77ee1cc2..aafb404e 100644 --- a/src/Http/Controllers/OAuthServer/ResourceController.php +++ b/src/Http/Controllers/OAuthServer/ResourceController.php @@ -6,10 +6,13 @@ use Fleetbase\Auth\OAuthServer\OAuthScopes; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Middleware\AuthenticateOAuthToken; +use Fleetbase\Models\ApiCredential; use Fleetbase\Models\Company; use Fleetbase\Support\AppReview; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Str; /** * Endpoints an app calls with an access token from "Continue with Fleetbase". @@ -39,6 +42,121 @@ public function organization(Request $request): JsonResponse 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}. * 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/OAuthClient.php b/src/Models/OAuthClient.php index 7f03b7ea..1f1ca651 100644 --- a/src/Models/OAuthClient.php +++ b/src/Models/OAuthClient.php @@ -63,6 +63,8 @@ class OAuthClient extends EloquentModel 'official', 'development', 'created_by_uuid', + 'software_id', + 'registration_key', ]; /** @@ -105,6 +107,22 @@ protected static function booted(): void }); } + /** + * 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. */ diff --git a/src/Models/OAuthGrant.php b/src/Models/OAuthGrant.php index 781e9e97..fddb0055 100644 --- a/src/Models/OAuthGrant.php +++ b/src/Models/OAuthGrant.php @@ -132,9 +132,52 @@ public function revoke(): void } 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. * diff --git a/src/routes.php b/src/routes.php index 3058955b..b4ebb2e7 100644 --- a/src/routes.php +++ b/src/routes.php @@ -42,11 +42,18 @@ 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); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php index 1013d96d..f8da100f 100644 --- a/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php +++ b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php @@ -395,7 +395,7 @@ function oauth_admin_request(string $method = 'GET', array $input = []): AdminRe [$code, $output] = oauth_console_command(new OAuthServerPrune(), ['--days' => '7']); expect($code)->toBe(0) - ->and($output)->toContain('Deleted 1 codes, 1 access tokens and 1 refresh tokens.') + ->and($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/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']); + }); +});