Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

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

return new class extends Migration {
/**
* Run the migrations.
*
* Adds the `microsoft_user_id` column that the Office365 / Azure AD
* OAuth flow stamps on user accounts authenticated via Microsoft
* identity (issue #453). Companion to the existing
* `add_social_login_columns_to_users_table` migration which carries
* `apple_user_id`, `facebook_user_id`, `google_user_id`.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('microsoft_user_id')->nullable()->unique()->after('google_user_id');
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('microsoft_user_id');
});
}
};
134 changes: 134 additions & 0 deletions src/Auth/FacebookVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace Fleetbase\Auth;

use GuzzleHttp\Client as GuzzleClient;

/**
* Server-side verifier for Facebook Login access tokens.
*
* The Facebook JavaScript / native SDKs hand a short-lived `accessToken`
* to the client after a successful login. The server MUST validate that
* token before trusting any identity claim — Facebook's official guidance
* is to call the Graph API `/debug_token` endpoint authenticated with
* an app access token (`{app_id}|{app_secret}`) and confirm:
*
* 1. The returned `data.app_id` matches our configured `services.facebook.app_id`
* 2. The token is `data.is_valid === true`
* 3. The `data.user_id` is non-empty
*
* Then a follow-up GET to `/me?fields=id,email,name` returns the profile
* fields needed for find-or-create user logic.
*
* The previous storefront implementation (`CustomerController::loginWithFacebook`)
* accepted `facebookUserId` from the request body without server-side
* verification — anyone could POST a forged identifier and impersonate
* another Facebook user. This class closes that gap for the Console OAuth
* flow and should be backported to the storefront controller separately.
*
* Config keys required:
* - services.facebook.app_id — public, also accepted as request param
* - services.facebook.app_secret — server-side only
*/
class FacebookVerifier
{
private const GRAPH_API_BASE = 'https://graph.facebook.com/v18.0';

/**
* Verify a Facebook access token and return the verified profile.
*
* @return array|null `['user_id', 'email', 'name']` when valid, `null` otherwise
*/
public static function verifyAccessToken(string $accessToken, ?string $clientAppId = null): ?array
{
$configuredAppId = config('services.facebook.app_id');
$appSecret = config('services.facebook.app_secret');

if (!$configuredAppId || !$appSecret) {
logger()->error('Facebook OAuth not configured — services.facebook.app_id / app_secret missing');

return null;
}

// Defence-in-depth: if the client supplied an app_id, refuse to verify
// a token claiming a different app. Stops a token issued for another
// Facebook app from logging into this one even if Graph API debug_token
// were ever to misreport.
if ($clientAppId !== null && $clientAppId !== $configuredAppId) {
logger()->warning('Facebook OAuth client app_id mismatch', [
'expected' => $configuredAppId,
'received' => $clientAppId,
]);

return null;
}

$http = self::httpClient();
$appToken = $configuredAppId . '|' . $appSecret;

try {
// Step 1: debug the user's access token using the app token.
$debugResp = $http->get(self::GRAPH_API_BASE . '/debug_token', [
'query' => [
'input_token' => $accessToken,
'access_token' => $appToken,
],
]);
$debugBody = json_decode((string) $debugResp->getBody(), true);
$data = data_get($debugBody, 'data');

if (!is_array($data)) {
logger()->warning('Facebook debug_token returned no data', ['body' => $debugBody]);

return null;
}
if (data_get($data, 'is_valid') !== true) {
logger()->info('Facebook access token reported invalid', ['data' => $data]);

return null;
}
if (data_get($data, 'app_id') !== $configuredAppId) {
logger()->warning('Facebook token issued for a different app', [
'expected' => $configuredAppId,
'actual' => data_get($data, 'app_id'),
]);

return null;
}
$userId = data_get($data, 'user_id');
if (!$userId) {
return null;
}

// Step 2: pull the profile (email, name) using the user's access token.
$meResp = $http->get(self::GRAPH_API_BASE . '/me', [
'query' => [
'fields' => 'id,email,name',
'access_token' => $accessToken,
],
]);
$me = json_decode((string) $meResp->getBody(), true);

return [
'user_id' => (string) $userId,
'email' => data_get($me, 'email'),
'name' => data_get($me, 'name'),
];
} catch (\Throwable $e) {
logger()->error('Facebook token verification failed: ' . $e->getMessage());

return null;
}
}

private static function httpClient(): GuzzleClient
{
return new GuzzleClient([
'timeout' => 8.0,
'connect_timeout' => 4.0,
// In local development we routinely run behind self-signed certs
// (mirrors the GoogleVerifier::verifyIdToken pattern).
'verify' => config('app.debug') !== true && app()->environment('production'),
]);
}
}
149 changes: 149 additions & 0 deletions src/Auth/Office365Verifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

namespace Fleetbase\Auth;

use Firebase\JWT\JWK;
use Fleetbase\Auth\Signers\AppleSignerInMemory;
use Fleetbase\Auth\Signers\AppleSignerNone;
use GuzzleHttp\Client as GuzzleClient;
use Illuminate\Support\Facades\Cache;
use Lcobucci\Clock\SystemClock;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Validation\Constraint\HasClaimWithValue;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;

/**
* Server-side verifier for Microsoft / Office365 ID tokens.
*
* Mirrors the AppleVerifier pattern (lcobucci/jwt parse + JWKS lookup
* + RS256 signature check) against Microsoft's identity platform:
*
* - Issuer: https://login.microsoftonline.com/{tenant}/v2.0
* - JWKS: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys
* - Audience: the registered application client_id
*
* For multi-tenant apps (`services.microsoft.tenant = 'common'`) the
* issuer in the token will contain the user's home tenant UUID rather
* than 'common'. We therefore validate the issuer with a prefix check
* rather than a strict equals.
*
* Config keys required:
* - services.microsoft.client_id — registered Azure app client id (audience)
* - services.microsoft.tenant — 'common' (multi-tenant) or a tenant uuid/domain
*/
class Office365Verifier
{
private const CACHE_DURATION = 300; // Cache Microsoft JWKS for 5 minutes
private const ISSUER_PREFIX = 'https://login.microsoftonline.com/';

/**
* Verify a Microsoft ID JWT and return the verified profile.
*
* @return array|null `['user_id', 'email', 'name', 'tenant_id']` when valid, `null` otherwise
*/
public static function verifyIdToken(string $idToken): ?array
{
$clientId = config('services.microsoft.client_id');
$tenant = config('services.microsoft.tenant', 'common');

if (!$clientId) {
logger()->error('Microsoft OAuth not configured — services.microsoft.client_id missing');

return null;
}

try {
// lcobucci/jwt requires *some* configuration even when we override
// the signer per-call — match AppleVerifier's bootstrap pattern.
$jwtContainer = Configuration::forSymmetricSigner(
new AppleSignerNone(),
AppleSignerInMemory::plainText('')
);

$token = $jwtContainer->parser()->parse($idToken);
$kid = $token->headers()->get('kid');
if (!$kid) {
logger()->warning('Microsoft ID token missing kid header');

return null;
}

$jwks = self::fetchJwks($tenant);
$keys = JWK::parseKeySet($jwks);
if (!isset($keys[$kid])) {
logger()->warning('Microsoft ID token kid not in JWKS', ['kid' => $kid]);

return null;
}

$publicKey = openssl_pkey_get_details($keys[$kid]->getKeyMaterial());

$constraints = [
new SignedWith(new Sha256(), AppleSignerInMemory::plainText($publicKey['key'])),
new PermittedFor($clientId),
new LooseValidAt(SystemClock::fromSystemTimezone()),
];

if (!$jwtContainer->validator()->validate($token, ...$constraints)) {
logger()->info('Microsoft ID token failed validation constraints');

return null;
}

// Issuer prefix check — accept both single-tenant + multi-tenant
// (token issuer contains the user's home tenant UUID in `common`).
$issuer = (string) $token->claims()->get('iss');
if (!str_starts_with($issuer, self::ISSUER_PREFIX)) {
logger()->warning('Microsoft ID token has unexpected issuer', ['iss' => $issuer]);

return null;
}

// Microsoft uses `oid` (object id, immutable per tenant) as the
// stable user identifier. `sub` is per-application-pairwise and
// also stable, but `oid` is the conventional choice when the same
// user may sign into multiple Microsoft apps.
$oid = (string) $token->claims()->get('oid');
if (!$oid) {
logger()->warning('Microsoft ID token missing oid claim');

return null;
}

$email = $token->claims()->get('email')
?? $token->claims()->get('preferred_username');
$name = $token->claims()->get('name');
$tid = (string) $token->claims()->get('tid');

return [
'user_id' => $oid,
'email' => $email ? (string) $email : null,
'name' => $name ? (string) $name : null,
'tenant_id' => $tid ?: null,
];
} catch (\Throwable $e) {
logger()->error('Microsoft ID token verification failed: ' . $e->getMessage());

return null;
}
}

private static function fetchJwks(string $tenant): array
{
$cacheKey = "microsoft-jwks:{$tenant}";

return Cache::remember($cacheKey, self::CACHE_DURATION, function () use ($tenant) {
$url = self::ISSUER_PREFIX . rawurlencode($tenant) . '/discovery/v2.0/keys';
$response = (new GuzzleClient([
'timeout' => 8.0,
'connect_timeout' => 4.0,
'verify' => config('app.debug') !== true && app()->environment('production'),
]))->get($url);

return json_decode((string) $response->getBody(), true);
});
}
}
Loading