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
11 changes: 6 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
}
],
"require": {
"php": "^8.0",
"php": "^8.1",
"aws/aws-sdk-php-laravel": "^3.7",
"fleetbase/countries": "^0.8.3",
"fleetbase/laravel-mysql-spatial": "^1.0.2",
Expand All @@ -42,23 +42,24 @@
"laravel-notification-channels/fcm": "^4.1",
"laravel-notification-channels/twilio": "^3.3",
"laravel/sanctum": "3.2.4",
"laravel/socialite": "^5.31",
"lcobucci/clock": "3.3.1",
"lcobucci/jwt": "^5.4",
"maatwebsite/excel": "^3.1",
"mossadal/math-parser": "^1.3",
"phpoffice/phpspreadsheet": "^1.28",
"phrity/websocket": "^1.7",
"rlanvin/php-rrule": "^2.4",
"sentry/sentry-laravel": "*",
"spatie/laravel-activitylog": "^4.7",
"spatie/laravel-google-cloud-storage": "^2.2",
"spatie/laravel-pdf": "^1.9",
"spatie/laravel-permission": "^6.3",
"spatie/laravel-responsecache": "^7.5",
"spatie/laravel-schedule-monitor": "^3.7",
"spatie/laravel-sluggable": "^3.5",
"sqids/sqids": "^0.4.1",
"xantios/mimey": "^2.2.0",
"spatie/laravel-pdf": "^1.9",
"mossadal/math-parser": "^1.3",
"rlanvin/php-rrule": "^2.4"
"xantios/mimey": "^2.2.0"
},
"require-dev": {
"cknow/laravel-money": "^7.2",
Expand Down
166 changes: 166 additions & 0 deletions config/oauth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

use Fleetbase\Auth\OAuth\Drivers\AppleDriver;
use Fleetbase\Auth\OAuth\Drivers\GithubDriver;
use Fleetbase\Auth\OAuth\Drivers\GoogleDriver;
use Fleetbase\Auth\OAuth\Drivers\MicrosoftDriver;

/*
|--------------------------------------------------------------------------
| OAuth / OIDC sign-in
|--------------------------------------------------------------------------
|
| Fleetbase resolves every value here from the database first (the admin
| settings UI writes `system.oauth`) and falls back to these env-backed
| defaults. That ordering is what lets a self-hosted operator configure
| providers entirely through the environment, while a Fleetbase Cloud admin
| configures them through the console.
|
| Secrets set through the admin UI are stored encrypted. Secrets set through
| the environment are read as-is — the environment is already trusted.
|
| These values are read exclusively through OAuthConfigRepository, never with
| a bare env() call: `config:cache` is in active use, and env() returns null
| under a cached config.
|
*/

return [
/*
|--------------------------------------------------------------------------
| Global switches
|--------------------------------------------------------------------------
|
| `enabled` is the kill switch for the whole feature. `allow_registration`
| separately controls whether an unrecognised provider identity may start a
| Fleetbase signup, so an operator can offer OAuth sign-in to existing users
| without opening self-service registration.
|
*/
'enabled' => env('OAUTH_ENABLED', true),
'allow_registration' => env('OAUTH_ALLOW_REGISTRATION', true),

/*
|--------------------------------------------------------------------------
| Automatic linking
|--------------------------------------------------------------------------
|
| When a provider identity is not linked yet but its verified email matches
| exactly one existing console account (type `admin` or `user`) whose own
| email is confirmed, link it and sign them in instead of asking them to
| sign in some other way and link it by hand. Two-factor still applies, and
| the account holder is emailed. See OAuthController::autoLinkCandidate().
|
*/
'auto_link' => env('OAUTH_AUTO_LINK', true),

/*
|--------------------------------------------------------------------------
| Console landing path
|--------------------------------------------------------------------------
|
| Where the callback sends the browser once the provider handshake is done.
| The host is always taken from the console configuration — never from the
| request — so this is a path, not a URL.
|
*/
'console_callback_path' => env('OAUTH_CONSOLE_CALLBACK_PATH', '/auth/oauth/callback'),

/*
|--------------------------------------------------------------------------
| Redirect base
|--------------------------------------------------------------------------
|
| The public origin of this API, used to build the redirect_uri handed to
| providers. Defaults to app.url. It must match what is registered in each
| provider's console byte for byte.
|
*/
'redirect_base' => env('OAUTH_REDIRECT_BASE'),

/*
|--------------------------------------------------------------------------
| Strict IP binding
|--------------------------------------------------------------------------
|
| Off by default: a phone that moves between wifi and cellular mid-flow
| legitimately changes address, and failing those users closed costs more
| than this binding is worth. Operators who can guarantee stable addressing
| can turn it into a hard failure.
|
*/
'strict_ip_binding' => env('OAUTH_STRICT_IP_BINDING', false),

/*
|--------------------------------------------------------------------------
| Token lifetimes (seconds)
|--------------------------------------------------------------------------
|
| authorization — the provider round trip. Generous: a user may have to
| complete MFA at the provider.
| handoff — callback to console exchange. Deliberately tight; the
| browser redeems it immediately.
| registration_intent — how long a verified identity may sit unused while
| the user fills in the signup wizard.
|
*/
'ttl' => [
'authorization' => (int) env('OAUTH_TTL_AUTHORIZATION', 600),
'handoff' => (int) env('OAUTH_TTL_HANDOFF', 120),
'registration_intent' => (int) env('OAUTH_TTL_REGISTRATION_INTENT', 900),
],

/*
|--------------------------------------------------------------------------
| Providers
|--------------------------------------------------------------------------
|
| Adding a provider later means: one class under Auth/OAuth/Drivers, one
| Socialite subclass if Socialite core does not ship the protocol, and one
| entry here. No route, controller, migration or console change — the
| registry, the {provider} route parameter and the admin UI schema are all
| driven off this map and the driver's configSchema().
|
*/
'providers' => [
'google' => [
'driver' => GoogleDriver::class,
'enabled' => env('OAUTH_GOOGLE_ENABLED', false),
'client_id' => env('OAUTH_GOOGLE_CLIENT_ID'),
'client_secret' => env('OAUTH_GOOGLE_CLIENT_SECRET'),
// Restrict sign-in to a Google Workspace domain. Enforced server side
// against the verified `hd` claim, not just sent as a request hint.
'hosted_domain' => env('OAUTH_GOOGLE_HOSTED_DOMAIN'),
],

'microsoft' => [
'driver' => MicrosoftDriver::class,
'enabled' => env('OAUTH_MICROSOFT_ENABLED', false),
'client_id' => env('OAUTH_MICROSOFT_CLIENT_ID'),
'client_secret' => env('OAUTH_MICROSOFT_CLIENT_SECRET'),
// 'common' accepts both work/school and personal accounts. A tenant
// id or domain restricts sign-in to that tenant — and is what makes
// the provider's email assertion trustworthy. See MicrosoftDriver.
'tenant' => env('OAUTH_MICROSOFT_TENANT', 'common'),
],

'github' => [
'driver' => GithubDriver::class,
'enabled' => env('OAUTH_GITHUB_ENABLED', false),
'client_id' => env('OAUTH_GITHUB_CLIENT_ID'),
'client_secret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
],

'apple' => [
'driver' => AppleDriver::class,
'enabled' => env('OAUTH_APPLE_ENABLED', false),
// The Services ID, e.g. io.fleetbase.console — not the app bundle id.
'client_id' => env('OAUTH_APPLE_CLIENT_ID'),
'team_id' => env('OAUTH_APPLE_TEAM_ID'),
'key_id' => env('OAUTH_APPLE_KEY_ID'),
// The contents of the .p8 signing key. Apple has no static client
// secret; one is minted as a short-lived ES256 JWT from this key.
'private_key' => env('OAUTH_APPLE_PRIVATE_KEY'),
],
],
];
56 changes: 56 additions & 0 deletions migrations/2026_09_18_000001_create_oauth_identities_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

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

return new class extends Migration {
/**
* Run the migrations.
*
* Stores the link between a Fleetbase user and an external OAuth/OIDC identity.
*
* Authentication keys on (provider, provider_user_id) — never on email — so the unique
* index below is a security control, not just a data-integrity one: it is what guarantees
* a provider subject can never be claimed by two Fleetbase accounts.
*
* Deliberately NOT soft-deleting. MySQL unique indexes include soft-deleted rows, so a
* soft-deleted identity would permanently block re-linking that same provider account —
* which is exactly what a user does right after an accidental unlink. Unlinks are audited
* through spatie/laravel-activitylog instead.
*/
public function up(): void
{
Schema::create('oauth_identities', function (Blueprint $table) {
$table->increments('id');
$table->uuid('uuid')->nullable()->index();
$table->foreignUuid('user_uuid')->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE');

$table->string('provider', 40);
// 191 keeps the composite unique index inside the utf8mb4 767-byte index limit.
$table->string('provider_user_id', 191);

// The address the provider reported at link time. Never authoritative for lookup;
// kept so an admin can see which account an identity belongs to.
$table->string('provider_email')->nullable();
// Whether the provider ASSERTED the address as verified. Defaults false: an
// unknown verification state must never be recorded as verified.
$table->boolean('email_verified')->default(false);

$table->json('meta')->nullable();
$table->timestamp('last_login_at')->nullable();
$table->timestamps();

$table->unique(['provider', 'provider_user_id'], 'oauth_identities_provider_subject_unique');
$table->index(['user_uuid', 'provider']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_identities');
}
};
63 changes: 63 additions & 0 deletions migrations/2026_09_18_000002_create_oauth_states_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

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

return new class extends Migration {
/**
* Run the migrations.
*
* Short-lived, single-use tokens for the three legs of the OAuth flow:
*
* authorization — CSRF state + PKCE verifier, issued at /redirect, consumed at /callback
* handoff — carries the resolved profile from /callback to /exchange
* registration_intent — proves a verified provider identity to the signup endpoints
*
* This is a table rather than a cache entry for a correctness reason, not a stylistic one:
* api/.env.example ships CACHE_DRIVER=file, so with more than one app node the /redirect leg
* and the /callback leg would not share a store and the flow would break outright. Laravel's
* FileStore also has no atomic add(), so single-use could not be enforced. A table gives
* cross-node correctness and atomic single-use on every driver.
*
* Only the sha256 of each token is stored — the same property Sanctum relies on, so a
* database dump yields no usable tokens.
*/
public function up(): void
{
Schema::create('oauth_states', function (Blueprint $table) {
$table->increments('id');
$table->uuid('uuid')->nullable()->index();

$table->string('purpose', 24)->index();
$table->char('token_hash', 64)->unique();

$table->string('provider', 40)->nullable()->index();
$table->string('intent', 16)->nullable();

// Set for link flows (the already-authenticated user) and once a registration
// intent has been redeemed. Null for anonymous login/signup authorization rows.
$table->foreignUuid('user_uuid')->nullable()->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE');

// Encrypted JSON: PKCE verifier, return path, resolved provider profile.
$table->text('payload')->nullable();

// HMAC of the originating IP. Never the raw address.
$table->char('ip_hash', 64)->nullable();

$table->timestamp('expires_at')->index();
$table->timestamp('consumed_at')->nullable();
$table->timestamps();

$table->index(['purpose', 'expires_at']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_states');
}
};
Loading
Loading