Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
27 changes: 27 additions & 0 deletions config/oauth-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
],
],
];
50 changes: 50 additions & 0 deletions migrations/2026_10_27_000001_add_oauth_registration_columns.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* - oauth_clients.software_id and registration_key: clients that registered themselves at
* POST /oauth/register (RFC 7591), one per install of a piece of software such as Fleetbase
* for WooCommerce. The key is a hash of the software and its redirect URI, so registering
* again finds the same client.
* - api_credentials.oauth_client_id and oauth_installation: the app that created the key
* through the api-key:create scope, and the installation it was made for when one client
* serves many (one Shopify app, many shops). Asking again returns the same key, and revoking
* the app's last grant in the organization expires them.
*/
public function up(): void
{
Schema::table('oauth_clients', function (Blueprint $table) {
$table->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']);
});
}
};
4 changes: 4 additions & 0 deletions src/Auth/OAuthServer/OAuthScopes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/Auth/OAuthServer/OAuthServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
17 changes: 13 additions & 4 deletions src/Console/Commands/OAuthServerPrune.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/Http/Controllers/Internal/v1/OAuthAppController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
}
Expand Down
105 changes: 105 additions & 0 deletions src/Http/Controllers/OAuthServer/RegistrationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

namespace Fleetbase\Http\Controllers\OAuthServer;

use Fleetbase\Auth\OAuthServer\OAuthScopes;
use Fleetbase\Auth\OAuthServer\Repositories\ClientRepository;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\OAuthClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

/**
* POST /oauth/register: dynamic client registration (RFC 7591), for software installed on many sites.
*
* A plugin installed on a merchant's own domain cannot share one registered redirect URI, so each
* install registers a public PKCE client for its own. Only software listed under
* `oauth-server.registration.software` may register, with the scopes listed there; the request
* names the software and exactly one redirect URI, nothing else is taken from it. The client is
* never official and is named after the redirect host, so the consent screen always shows which
* site is asking. Registering again with the same software and redirect URI returns the same
* client.
*/
class RegistrationController extends Controller
{
public function register(Request $request): JsonResponse
{
if (!config('oauth-server.registration.enabled', true)) {
return static::error('registration_disabled', 'Client registration is turned off on this server.', 404);
}

$softwareId = $request->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<string, mixed>
*/
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']);
}
}
Loading
Loading