From 33f8ae125253f35a18ba9855b34012b40fc6de8a Mon Sep 17 00:00:00 2001 From: Amein-Eskinder Date: Wed, 13 May 2026 02:16:00 +0300 Subject: [PATCH] feat(auth): OAuth Console login (Google, Facebook, Office365, Apple) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements server-side OAuth handlers for Console login per fleetbase/fleetbase#453. * AuthController::loginWith{Google,Facebook,Office365,Apple} + shared oauthRespond helper handling find-or-create-and-link user, 2FA gating, and Sanctum token issuance — response shape matches the native /int/v1/auth/login endpoint exactly. * New FacebookVerifier validates access tokens server-side via Graph API /debug_token authenticated with the app token; refuses if the client-supplied app_id mismatches the server config. The existing storefront CustomerController::loginWithFacebook trusts a client-supplied facebookUserId without verification — that gap is closed here for the Console flow and worth a separate backport to the storefront. * New Office365Verifier validates Microsoft ID tokens against the tenant JWKS via lcobucci/jwt, mirroring AppleVerifier's shape. * Existing GoogleVerifier and AppleVerifier are reused. The Apple handler wraps the verifier call in try/catch because AppleVerifier::verifyAppleJwt throws on malformed JWT input (Google/Facebook/Office365 verifiers return null on any failure — Apple's behaviour was inconsistent and would surface as a 500 to an end user POSTing junk). * User.\$fillable gains microsoft_user_id; companion migration adds the nullable+unique column to users. * Four new routes under /int/v1/auth/oauth/* sit behind the existing ThrottleRequests middleware. No new dependencies — google/apiclient and lcobucci/jwt are already in core-api/composer.json. --- ...0_add_microsoft_user_id_to_users_table.php | 37 +++ src/Auth/FacebookVerifier.php | 134 ++++++++++ src/Auth/Office365Verifier.php | 149 +++++++++++ .../Internal/v1/AuthController.php | 236 ++++++++++++++++++ src/Models/User.php | 1 + src/routes.php | 12 + 6 files changed, 569 insertions(+) create mode 100644 migrations/2026_05_13_120000_add_microsoft_user_id_to_users_table.php create mode 100644 src/Auth/FacebookVerifier.php create mode 100644 src/Auth/Office365Verifier.php diff --git a/migrations/2026_05_13_120000_add_microsoft_user_id_to_users_table.php b/migrations/2026_05_13_120000_add_microsoft_user_id_to_users_table.php new file mode 100644 index 00000000..73ad64c5 --- /dev/null +++ b/migrations/2026_05_13_120000_add_microsoft_user_id_to_users_table.php @@ -0,0 +1,37 @@ +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'); + }); + } +}; diff --git a/src/Auth/FacebookVerifier.php b/src/Auth/FacebookVerifier.php new file mode 100644 index 00000000..c2e08f66 --- /dev/null +++ b/src/Auth/FacebookVerifier.php @@ -0,0 +1,134 @@ +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'), + ]); + } +} diff --git a/src/Auth/Office365Verifier.php b/src/Auth/Office365Verifier.php new file mode 100644 index 00000000..bd33e838 --- /dev/null +++ b/src/Auth/Office365Verifier.php @@ -0,0 +1,149 @@ +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); + }); + } +} diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index ba9d8160..e3464906 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -2,6 +2,10 @@ namespace Fleetbase\Http\Controllers\Internal\v1; +use Fleetbase\Auth\AppleVerifier; +use Fleetbase\Auth\FacebookVerifier; +use Fleetbase\Auth\GoogleVerifier; +use Fleetbase\Auth\Office365Verifier; use Fleetbase\Events\UserCreatedNewCompany; use Fleetbase\Exceptions\InvalidVerificationCodeException; use Fleetbase\Http\Controllers\Controller; @@ -955,4 +959,236 @@ public function endImpersonation() return response()->json(['status' => 'ok', 'token' => $token->plainTextToken]); } + + // ----------------------------------------------------------------------- + // OAuth Console login (issue #453) + // ----------------------------------------------------------------------- + // + // Four POST endpoints — one per supported identity provider. The client + // (Console) obtains a provider-issued token via the provider's JS SDK + // and POSTs it here; we verify with the corresponding Verifier class + // and issue a Sanctum personal-access token matching the native login + // response shape (`{token, type}`). + // + // Account-linking policy: if a verified email or provider user-id + // matches an existing User, we link the OAuth identity to that user + // (stamping the `_user_id` column when previously null). If + // no match exists, we create a new User with `email_verified_at = now()` + // (the IdP already attested the email) and an empty `company_uuid` — + // the Console UI routes new users into join-or-create-org from there. + // + // 2FA: when 2FA is enabled on the user record, OAuth login still goes + // through the 2FA challenge — consistent with password login (§AC of + // the parent issue does not exempt OAuth from 2FA). + + /** + * Authenticate a Console user via a Google ID token. + * + * Request body: + * - idToken (string, required): the Google ID token issued client-side + * - clientId (string, required): the Google OAuth client_id the token + * was issued for. The server verifies + * the token's `aud` claim against it. + */ + public function loginWithGoogle(Request $request) + { + $idToken = $request->input('idToken'); + $clientId = $request->input('clientId'); + if (!$idToken || !$clientId) { + return response()->error('Missing required Google authentication parameters.', 400); + } + + $payload = GoogleVerifier::verifyIdToken($idToken, $clientId); + if (!$payload) { + return response()->error('Google Sign-In authentication is not valid.', 400); + } + + return $this->oauthRespond( + providerColumn: 'google_user_id', + providerUserId: (string) data_get($payload, 'sub'), + email: data_get($payload, 'email'), + name: data_get($payload, 'name'), + ); + } + + /** + * Authenticate a Console user via a Facebook access token. + * + * Request body: + * - accessToken (string, required): the Facebook access token from the + * client-side JS SDK + * - appId (string, optional): the client's Facebook app_id; + * when present, must match the + * server's configured app_id + */ + public function loginWithFacebook(Request $request) + { + $accessToken = $request->input('accessToken'); + $appId = $request->input('appId'); + if (!$accessToken) { + return response()->error('Missing required Facebook authentication parameters.', 400); + } + + $payload = FacebookVerifier::verifyAccessToken($accessToken, $appId); + if (!$payload) { + return response()->error('Facebook Sign-In authentication is not valid.', 400); + } + + return $this->oauthRespond( + providerColumn: 'facebook_user_id', + providerUserId: (string) $payload['user_id'], + email: $payload['email'] ?? null, + name: $payload['name'] ?? null, + ); + } + + /** + * Authenticate a Console user via a Microsoft / Office365 ID token. + * + * Request body: + * - idToken (string, required): the Microsoft ID token from MSAL.js + * + * Audience + issuer are validated server-side against + * `services.microsoft.client_id` and `services.microsoft.tenant`. + */ + public function loginWithOffice365(Request $request) + { + $idToken = $request->input('idToken'); + if (!$idToken) { + return response()->error('Missing required Office365 authentication parameters.', 400); + } + + $payload = Office365Verifier::verifyIdToken($idToken); + if (!$payload) { + return response()->error('Office365 Sign-In authentication is not valid.', 400); + } + + return $this->oauthRespond( + providerColumn: 'microsoft_user_id', + providerUserId: (string) $payload['user_id'], + email: $payload['email'] ?? null, + name: $payload['name'] ?? null, + ); + } + + /** + * Authenticate a Console user via Apple Sign-In. + * + * Request body: + * - identityToken (string, required): the Apple identity JWT + * - appleUserId (string, required): the stable `sub` Apple assigns + * - email (string, optional): provided by Apple on first login + * only — pass through from client + * - name (string, optional): provided by Apple on first login + * only + */ + public function loginWithApple(Request $request) + { + $identityToken = $request->input('identityToken'); + $appleUserId = $request->input('appleUserId'); + $email = $request->input('email'); + $name = $request->input('name'); + + if (!$identityToken || !$appleUserId) { + return response()->error('Missing required Apple authentication parameters.', 400); + } + + // AppleVerifier::verifyAppleJwt lets parse / signature failures bubble + // up as exceptions (unlike Google/Facebook/Office365 verifiers, which + // return null on any failure). Wrap so malformed input becomes the + // same 400 the other three providers return instead of a 500. + try { + $appleVerified = AppleVerifier::verifyAppleJwt($identityToken); + } catch (\Throwable $e) { + logger()->info('Apple Sign-In verification raised an exception: ' . $e->getMessage()); + $appleVerified = false; + } + if (!$appleVerified) { + return response()->error('Apple Sign-In authentication is not valid.', 400); + } + + return $this->oauthRespond( + providerColumn: 'apple_user_id', + providerUserId: (string) $appleUserId, + email: $email, + name: $name, + ); + } + + /** + * Shared finalisation for all four OAuth providers. + * + * Looks up an existing user by verified email OR provider user-id; + * links the identity onto the existing user if found; otherwise creates + * a fresh user with the IdP-attested email already verified. Then runs + * the same 2FA + verification gate password login uses and issues a + * Sanctum token. + */ + protected function oauthRespond( + string $providerColumn, + string $providerUserId, + ?string $email, + ?string $name + ) { + // Normalise email to lowercase before any lookup — User::create + // and Auth::register both do this on the password path, and we + // must match so case-variant duplicates can't sneak in. + if ($email) { + $email = strtolower($email); + } + + $user = User::where(function ($query) use ($email, $providerColumn, $providerUserId) { + if ($email) { + $query->where('email', $email); + $query->orWhere($providerColumn, $providerUserId); + } else { + $query->where($providerColumn, $providerUserId); + } + })->first(); + + if (!$user) { + // New user via OAuth — IdP has attested the email so we stamp + // email_verified_at. No password is set; the account will only + // be usable via the same OAuth provider until the user opts in + // to set one. company_uuid is left null; the Console join / + // create-org flow picks them up. + $attributes = [ + 'email' => $email, + 'name' => $name, + $providerColumn => $providerUserId, + 'email_verified_at' => $email ? now() : null, + ]; + $user = User::create($attributes); + } elseif (!$user->{$providerColumn}) { + // Existing user — link the OAuth identity if not already stamped. + $user->{$providerColumn} = $providerUserId; + $user->save(); + } + + // 2FA is honoured on OAuth login too — consistent with password login. + if (TwoFactorAuth::isEnabled($user)) { + $twoFaSession = TwoFactorAuth::start($user); + + return response()->json([ + 'twoFaSession' => $twoFaSession, + 'isEnabled' => true, + ]); + } + + // Email verification is automatic for OAuth (IdP attested), but a + // user record could theoretically pre-exist as unverified (created + // by an admin invitation that didn't complete). Match the native + // login policy: admins bypass, everyone else needs a verified email. + if ($user->isNotVerified() && $user->isNotAdmin()) { + return response()->error('User is not verified.', 400, ['code' => 'not_verified']); + } + + $user->updateLastLogin(); + $token = $user->createToken($user->uuid); + + return response()->json([ + 'token' => $token->plainTextToken, + 'type' => $user->getType(), + ]); + } } diff --git a/src/Models/User.php b/src/Models/User.php index 10f7a28f..3902f0d0 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -147,6 +147,7 @@ class User extends Authenticatable 'apple_user_id', 'facebook_user_id', 'google_user_id', + 'microsoft_user_id', 'name', 'phone', 'date_of_birth', diff --git a/src/routes.php b/src/routes.php index 2b86680e..487be761 100644 --- a/src/routes.php +++ b/src/routes.php @@ -90,6 +90,18 @@ function ($router) { $router->prefix('v1')->namespace('v1')->group( function ($router) { $router->fleetbaseAuthRoutes(); + // OAuth Console login (issue #453) — public, throttled, pre-auth. + // Each provider has its own POST endpoint matching the verifier + // class in Fleetbase\Auth (Google, Facebook, Office365, Apple). + $router->group( + ['prefix' => 'auth/oauth', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], + function ($router) { + $router->post('google', 'AuthController@loginWithGoogle'); + $router->post('facebook', 'AuthController@loginWithFacebook'); + $router->post('office365', 'AuthController@loginWithOffice365'); + $router->post('apple', 'AuthController@loginWithApple'); + } + ); $router->group( ['prefix' => 'installer', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], function ($router) {