abortIfInvalidProvider($provider); return Socialite::driver($provider)->redirect(); } /** * Handle the provider callback and authenticate the user. */ public function handleProviderCallback(string $provider): RedirectResponse { $this->abortIfInvalidProvider($provider); try { /** @var SocialiteUser $socialUser */ $socialUser = Socialite::driver($provider)->user(); } catch (Throwable) { return redirect()->route('login') ->withErrors(['oauth' => 'Authentication failed. Please try again.']); } $providerId = (string) $socialUser->getId(); $providerEmail = $this->resolveEmail($socialUser); $verified = $this->isEmailVerifiedByProvider($provider, $socialUser); // ── 1. Provider account already linked → login ─────────────────────── $existing = SocialAccount::query() ->where('provider', $provider) ->where('provider_id', $providerId) ->with('user') ->first(); if ($existing !== null && $existing->user !== null) { return $this->loginAndRedirect($existing->user); } // ── 2. Email match → link to existing account ──────────────────────── // Covers both verified and unverified users: if the OAuth provider // has confirmed this email we can safely link it and mark it verified, // preventing a duplicate-email insert when the user had started // registration via email but never finished verification. if ($providerEmail !== null && $verified) { $userByEmail = User::query() ->where('email', strtolower($providerEmail)) ->first(); if ($userByEmail !== null) { // If their email was not yet verified, promote it now — the // OAuth provider has already verified it on our behalf. if ($userByEmail->email_verified_at === null) { $userByEmail->forceFill([ 'email_verified_at' => now(), 'is_active' => true, // Keep their onboarding step unless already complete 'onboarding_step' => $userByEmail->onboarding_step === 'email' ? 'username' : ($userByEmail->onboarding_step ?? 'username'), ])->save(); } $this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar()); return $this->loginAndRedirect($userByEmail); } } // ── 3. Provider email not verified → reject auto-link ──────────────── if ($providerEmail !== null && ! $verified) { return redirect()->route('login') ->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']); } // ── 4. No email at all → cannot proceed ────────────────────────────── if ($providerEmail === null) { return redirect()->route('login') ->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']); } // ── 5. New user creation ────────────────────────────────────────────── try { $user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail); } catch (UniqueConstraintViolationException) { // Race condition: another request inserted the same email between // the lookup above and this insert. Fetch and link instead. $user = User::query()->where('email', strtolower($providerEmail))->first(); if ($user === null) { return redirect()->route('login') ->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']); } $this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar()); } return $this->loginAndRedirect($user); } // ─── Private helpers ───────────────────────────────────────────────────── private function abortIfInvalidProvider(string $provider): void { abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404); } /** * Create social_accounts row linked to a user. */ private function createSocialAccount( User $user, string $provider, string $providerId, ?string $providerEmail, ?string $avatar ): void { SocialAccount::query()->updateOrCreate( ['provider' => $provider, 'provider_id' => $providerId], [ 'user_id' => $user->id, 'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null, 'avatar' => $avatar, ] ); } /** * Create a brand-new user from OAuth data. */ private function createOAuthUser( SocialiteUser $socialUser, string $provider, string $providerId, string $providerEmail ): User { $user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User { $name = $this->resolveDisplayName($socialUser, $providerEmail); $user = User::query()->create([ 'username' => null, 'name' => $name, 'email' => strtolower($providerEmail), 'email_verified_at' => now(), 'password' => Hash::make(Str::random(64)), 'is_active' => true, 'onboarding_step' => 'username', 'username_changed_at' => now(), ]); $this->createSocialAccount( $user, $provider, $providerId, $providerEmail, $socialUser->getAvatar() ); return $user; }); return $user; } /** * Login the user and redirect appropriately. */ private function loginAndRedirect(User $user): RedirectResponse { Auth::login($user, remember: true); request()->session()->regenerate(); $step = strtolower((string) ($user->onboarding_step ?? '')); if (in_array($step, ['username', 'password'], true)) { return redirect()->route('setup.username.create'); } return redirect()->intended(route('dashboard', absolute: false)); } /** * Resolve a usable display name from the social user. */ private function resolveDisplayName(SocialiteUser $socialUser, string $email): string { $name = trim((string) ($socialUser->getName() ?? '')); if ($name !== '') { return $name; } return Str::before($email, '@'); } /** * Best-effort email resolution. Apple can return null email on repeat logins. */ private function resolveEmail(SocialiteUser $socialUser): ?string { $email = $socialUser->getEmail(); if ($email === null || $email === '') { return null; } return strtolower(trim($email)); } /** * Determine whether the provider has verified the user's email. * * - Google: returns email_verified flag in raw data * - Discord: returns verified flag in raw data * - Apple: only issues tokens for verified Apple IDs */ private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool { $raw = (array) ($socialUser->getRaw() ?? []); return match ($provider) { 'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN), 'discord' => (bool) ($raw['verified'] ?? false), 'apple' => true, // Apple only issues tokens for verified Apple IDs default => false, }; } }