Skip to content
Merged
16 changes: 14 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, and safer upload names
# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, App Review access, and Sign in with Fleetbase

## Added

- **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=<unix>,v1=<hmac_sha256("<t>.<body>", 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** (OAuth 2.0 / OpenID Connect provider). 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).

## 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.

## Upgrade Steps

- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column.
- 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`).
- 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`.
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
50 changes: 50 additions & 0 deletions config/oauth-server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
|--------------------------------------------------------------------------
| Fleetbase as an OAuth 2.0 / OpenID Connect provider
|--------------------------------------------------------------------------
|
| "Continue with Fleetbase": registered apps sign people in with their
| Fleetbase account and, with consent, act on one of their organizations.
| Not to be confused with config/oauth.php, which is Fleetbase signing
| people in WITH Google, Microsoft, GitHub and Apple.
|
*/

return [
'enabled' => 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',
];
48 changes: 48 additions & 0 deletions migrations/2026_10_24_000000_create_review_accesses_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

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

return new class extends Migration {
/**
* Run the migrations.
*
* App Review access: one row per thing an app signs reviewers in to (a Storefront store
* or network, an organization's Navigator app, ...). Apple and Google reviewers can't
* receive a texted code, so the owner turns access on and gives the app store a fixed
* fictional number and a code. `subject_type` is the type an extension registered with
* Fleetbase\Support\AppReview. The code is only ever stored hashed.
*/
public function up(): void
{
if (Schema::hasTable('review_accesses')) {
return;
}

Schema::create('review_accesses', function (Blueprint $table) {
$table->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');
}
};
115 changes: 115 additions & 0 deletions migrations/2026_10_25_000001_create_oauth_server_tables.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

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

return new class extends Migration {
/**
* Run the migrations.
*
* Tables for Fleetbase acting as an OAuth 2.0 / OpenID Connect provider ("Continue with
* Fleetbase"), on league/oauth2-server. Not to be confused with oauth_identities and
* oauth_states, which are Fleetbase signing people in WITH Google, Microsoft and others.
*
* - oauth_clients: apps registered by an admin. The id is the public client_id.
* - oauth_grants: one user's consent for one app and (optionally) one organization. Every
* code and token points at a grant, so revoking the grant revokes everything under it.
* - oauth_auth_codes, oauth_access_tokens, oauth_refresh_tokens: league's token records,
* each carrying the grant and the organization the user picked on the consent screen.
*
* Tokens and codes are never stored in a form that can be replayed: league hands the client
* an encrypted payload or a signed JWT, and these rows hold only the identifier inside it.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->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');
}
};
73 changes: 73 additions & 0 deletions src/Auth/OAuthServer/Entities/AccessTokenEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Fleetbase\Auth\OAuthServer\Entities;

use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token;
use League\OAuth2\Server\CryptKey;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;

/**
* An access token, handed to the client as an RS256 JWT.
*
* League's AccessTokenTrait is reimplemented here only to add `iss` and the key's `kid`, so a
* client can tell which provider and which key signed it. The claims league relies on (`jti`,
* `aud`, `sub`, `exp`, `nbf`, `scopes`) are unchanged, so league's BearerTokenValidator reads it.
*/
class AccessTokenEntity implements AccessTokenEntityInterface
{
use EntityTrait;
use TokenEntityTrait;

private ?CryptKey $privateKey = null;

/**
* The JWT, built once: the token response and the ID token's at_hash must see the same string.
*/
private ?string $jwt = null;

public function __construct(private string $issuer, private ?string $keyId = null)
{
}

public function setPrivateKey(CryptKey $privateKey)
{
$this->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();
}
}
15 changes: 15 additions & 0 deletions src/Auth/OAuthServer/Entities/AuthCodeEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Fleetbase\Auth\OAuthServer\Entities;

use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;

class AuthCodeEntity implements AuthCodeEntityInterface
{
use AuthCodeTrait;
use EntityTrait;
use TokenEntityTrait;
}
24 changes: 24 additions & 0 deletions src/Auth/OAuthServer/Entities/ClientEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Fleetbase\Auth\OAuthServer\Entities;

use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\Traits\ClientTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;

class ClientEntity implements ClientEntityInterface
{
use ClientTrait;
use EntityTrait;

/**
* @param array<int, string> $redirectUris
*/
public function __construct(string $identifier, string $name, array $redirectUris, bool $confidential)
{
$this->setIdentifier($identifier);
$this->name = $name;
$this->redirectUri = $redirectUris;
$this->isConfidential = $confidential;
}
}
13 changes: 13 additions & 0 deletions src/Auth/OAuthServer/Entities/RefreshTokenEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Fleetbase\Auth\OAuthServer\Entities;

use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;

class RefreshTokenEntity implements RefreshTokenEntityInterface
{
use EntityTrait;
use RefreshTokenTrait;
}
Loading
Loading