diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php
index 1c95544..75cc94f 100644
--- a/app/Http/Controllers/DashboardController.php
+++ b/app/Http/Controllers/DashboardController.php
@@ -36,15 +36,9 @@ public function launchSSO($id)
return redirect()->route('dashboard')->with('error', 'Access denied. You do not have permission to access this service.');
}
- // Check if it is a Microsoft service
- $isMicrosoft = str_contains($app->url, 'microsoft.com') ||
- str_contains($app->url, 'office.com') ||
- str_contains($app->url, 'office365.com') ||
- str_contains($app->url, 'outlook.com') ||
- $app->icon_svg === 'outlook' ||
- $app->icon_svg === 'teams';
-
- if ($isMicrosoft && $user->microsoft_id) {
+ // If user is logged in via Microsoft, route all service launches (including Keka)
+ // through the Microsoft SSO flow to ensure their browser has an active Microsoft session.
+ if ($user->microsoft_id) {
// Save target app URL in session so callback knows where to redirect
session(['sso_target_url' => $app->url]);
diff --git a/app/Http/Controllers/LoginController.php b/app/Http/Controllers/LoginController.php
index bb91cde..14373ff 100644
--- a/app/Http/Controllers/LoginController.php
+++ b/app/Http/Controllers/LoginController.php
@@ -114,6 +114,9 @@ public function handleMicrosoftCallback(Request $request)
if ($user->is_blocked) {
return redirect()->route('login')->with('error', 'Your account has been blocked. Please contact the administrator.');
}
+ if ($user->role === 'admin') {
+ return redirect()->route('admin.login')->with('error', 'Security protocol: Administrators are restricted to email and password authentication only.');
+ }
// Update existing user with Microsoft data if not already set
$user->update([
'microsoft_id' => $microsoftUser->getId(),
@@ -155,7 +158,26 @@ public function logout(Request $request)
$request->session()->invalidate();
$request->session()->regenerateToken();
- return redirect()->route('login')->with('success', 'Logged out successfully.');
+ // Redirect to Microsoft's logout URL to log the user out of Microsoft as well
+ try {
+ $microsoftLogoutUrl = Socialite::driver('microsoft')->getLogoutUrl(route('login'));
+ return redirect($microsoftLogoutUrl);
+ } catch (\Exception $e) {
+ return redirect()->route('login')->with('success', 'Logged out successfully.');
+ }
+ }
+
+ /**
+ * Handle front-channel logout from Microsoft.
+ */
+ public function frontChannelLogout(Request $request)
+ {
+ Auth::logout();
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ // Microsoft renders this in an iframe. A simple 200 response is expected.
+ return response('Logged out from SingleLogin', 200);
}
/**
diff --git a/public/Screenshot_3.png b/public/Screenshot_3.png
new file mode 100644
index 0000000..e2b9b6d
Binary files /dev/null and b/public/Screenshot_3.png differ
diff --git a/public/Screenshot_4.png b/public/Screenshot_4.png
new file mode 100644
index 0000000..42566e1
Binary files /dev/null and b/public/Screenshot_4.png differ
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php
index 5a2fbeb..4856abd 100644
--- a/resources/views/dashboard.blade.php
+++ b/resources/views/dashboard.blade.php
@@ -529,7 +529,15 @@
{{ $user->email }}
•
- {{ $user->role }}
+
+ @if($user->isAdmin())
+ Admin
+ @elseif($user->roles->isNotEmpty())
+ {{ $user->roles->pluck('name')->implode(', ') }}
+ @else
+ {{ ucfirst($user->role) }}
+ @endif
+
@if($user->microsoft_id)
Microsoft Linked
@endif
diff --git a/routes/web.php b/routes/web.php
index 2383844..71513b7 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -16,6 +16,7 @@
// Microsoft OAuth endpoints
Route::get('/auth/microsoft/redirect', [LoginController::class, 'redirectToMicrosoft'])->name('auth.microsoft.redirect');
Route::get('/auth/microsoft/callback', [LoginController::class, 'handleMicrosoftCallback'])->name('auth.microsoft.callback');
+Route::get('/auth/microsoft/logout', [LoginController::class, 'frontChannelLogout'])->name('auth.microsoft.logout');
// User Dashboard Portal (requires standard user role or admin access)
Route::middleware(['auth', 'role:user'])->group(function () {
diff --git a/tests/Feature/SecurityAndAdminTest.php b/tests/Feature/SecurityAndAdminTest.php
index a95c804..383448f 100644
--- a/tests/Feature/SecurityAndAdminTest.php
+++ b/tests/Feature/SecurityAndAdminTest.php
@@ -188,4 +188,92 @@ public function test_admin_cannot_authenticate_via_microsoft_oauth_callback(): v
$response->assertSessionHas('error', 'Security protocol: Administrators are restricted to email and password authentication only.');
$this->assertFalse(auth()->check());
}
+
+ /**
+ * Test local logout redirects to Microsoft global logout endpoint.
+ */
+ public function test_user_logout_redirects_to_microsoft_logout_page(): void
+ {
+ $user = User::create([
+ 'name' => 'Standard User',
+ 'email' => 'user@sentientgeeks.com',
+ 'role' => 'user',
+ ]);
+
+ $mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Provider');
+ $mockSocialite->shouldReceive('getLogoutUrl')
+ ->with(route('login'))
+ ->andReturn('https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=' . urlencode(route('login')));
+
+ \Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
+
+ $response = $this->actingAs($user)
+ ->get('/logout'); // supports GET for testing
+
+ $response->assertRedirect('https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=' . urlencode(route('login')));
+ $this->assertFalse(auth()->check());
+ }
+
+ /**
+ * Test Microsoft Front-channel logout callback terminates local session.
+ */
+ public function test_microsoft_front_channel_logout_clears_session(): void
+ {
+ $user = User::create([
+ 'name' => 'Standard User',
+ 'email' => 'user@sentientgeeks.com',
+ 'role' => 'user',
+ ]);
+
+ $this->actingAs($user);
+ $this->assertTrue(auth()->check());
+
+ $response = $this->get('/auth/microsoft/logout');
+
+ $response->assertStatus(200);
+ $response->assertSee('Logged out from SingleLogin');
+ $this->assertFalse(auth()->check());
+ }
+
+ /**
+ * Test that launching any service (like Keka) routes through Microsoft SSO flow
+ * if the user is Microsoft-linked.
+ */
+ public function test_sso_launch_redirects_to_microsoft_for_all_services_if_user_is_microsoft_linked(): void
+ {
+ $user = User::create([
+ 'name' => 'Standard User',
+ 'email' => 'user@sentientgeeks.com',
+ 'role' => 'user',
+ 'microsoft_id' => 'user-microsoft-id-123',
+ ]);
+
+ $app = App::create([
+ 'name' => 'Keka HR',
+ 'url' => 'https://sentientgeeks.keka.com',
+ 'color' => '#f27059',
+ 'tag' => 'HR Portal'
+ ]);
+
+ // Assign role with the app to user
+ $role = Role::create(['name' => 'Employee']);
+ $role->apps()->sync([$app->id]);
+ $user->roles()->sync([$role->id]);
+
+ $mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Provider');
+ $mockSocialite->shouldReceive('redirectUrl')->with(route('sso.callback'))->andReturnSelf();
+ $mockSocialite->shouldReceive('with')->with([
+ 'login_hint' => $user->email,
+ 'domain_hint' => 'organizations'
+ ])->andReturnSelf();
+ $mockSocialite->shouldReceive('redirect')
+ ->andReturn(redirect('https://login.microsoftonline.com/common/oauth2/v2.0/authorize'));
+
+ \Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
+
+ $response = $this->actingAs($user)
+ ->get(route('sso.launch', $app->id));
+
+ $response->assertRedirect('https://login.microsoftonline.com/common/oauth2/v2.0/authorize');
+ }
}