Refactor: Replace Outlook integration with Entra ID federation

- Removed legacy Outlook integration, including views, controllers, and models.
- Introduced Microsoft Entra ID OAuth flows with enhanced permission checks and token management.
- Migrated database structure: replaced `outlook_tokens` table with `oauth_tokens` table for multi-provider support.
- Added comprehensive tests for Entra ID federation, including connection flow, token refresh, and permission checks.
- Standardized OAuth implementation using `EntraController` and consolidated token models into `OauthToken`.
This commit is contained in:
= 2026-06-10 13:16:34 +00:00
parent d1637c5a35
commit 82d1cdb204
18 changed files with 825 additions and 359 deletions

View File

@ -15,5 +15,7 @@ public function __construct(
public string $duration,
public int $days_remaining,
public bool $is_warning,
public ?string $protocol_name = null,
public ?string $provider_slug = null,
) {}
}

View File

@ -13,4 +13,5 @@ enum ConnectionProtocolEnum: string
case OIDC = 'oidc';
case SAML = 'saml';
case OAUTH2 = 'oauth2';
case URL = 'url';
}

View File

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\OauthToken;
use Illuminate\Http\{RedirectResponse, Request};
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class EntraController extends Controller
{
// ── OAuth config from .env ────────────────────────────────────────────────
private string $tenantId;
private string $clientId;
private string $clientSecret;
private string $redirectUri;
private const PROVIDER = 'microsoft';
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
public function __construct()
{
$this->tenantId = config('services.microsoft.tenant_id');
$this->clientId = config('services.microsoft.client_id');
$this->clientSecret = config('services.microsoft.client_secret');
$this->redirectUri = route('entra.callback');
}
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
public function connect(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$state = Str::random(40);
session(['oauth_state' => $state]);
$query = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'response_mode' => 'query',
'scope' => self::SCOPES,
'state' => $state,
'prompt' => 'select_account',
]);
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
}
// ── Step 2: Handle callback, exchange code for tokens ────────────────────
public function callback(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
// CSRF state check
if ($request->state !== session('oauth_state')) {
return redirect()->route('dashboard.user')->with('entra_error', 'Invalid OAuth state. Please try again.');
}
if ($request->has('error')) {
return redirect()->route('dashboard.user')->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
}
// Exchange authorization code for tokens
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $request->code,
'redirect_uri' => $this->redirectUri,
'grant_type' => 'authorization_code',
]
);
if ($response->failed()) {
return redirect()->route('dashboard.user')->with('entra_error', 'Failed to retrieve tokens from Microsoft.');
}
$data = $response->json();
// Persist token for this user (upsert so re-connecting just updates)
OauthToken::updateOrCreate(
[
'user_id' => auth()->id(),
'provider' => self::PROVIDER,
],
[
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? null,
'token_type' => $data['token_type'] ?? 'Bearer',
'scopes' => self::SCOPES,
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
]
);
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft connected successfully!');
}
// ── Open Entra (refresh token silently if expired) ───────────────────────
public function openEntra(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$token = $user->oauthToken(self::PROVIDER);
if (! $token) {
return redirect()->route('entra.connect');
}
// If token is expired, try to refresh silently
if ($token->isExpired()) {
$refreshed = $this->refreshToken($token);
if (! $refreshed) {
// Refresh failed — force re-login
$token->delete();
return redirect()->route('entra.connect');
}
}
// Redirect to Outlook Web — SSO session handles silent sign-in
return redirect('https://outlook.office.com/mail/');
}
// ── Disconnect ────────────────────────────────────────────────────────────
public function disconnect(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$user->oauthToken(self::PROVIDER)?->delete();
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.');
}
// ── Internal: Refresh access token using refresh_token ───────────────────
private function refreshToken(OauthToken $token): bool
{
if (! $token->refresh_token) {
return false;
}
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'refresh_token' => $token->refresh_token,
'grant_type' => 'refresh_token',
'scope' => self::SCOPES,
]
);
if ($response->failed()) {
return false;
}
$data = $response->json();
$token->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
]);
return true;
}
}

View File

@ -1,185 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\OutlookToken;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\View\View;
class OutlookController extends Controller
{
// ── OAuth config from .env ────────────────────────────────────────────────
private string $tenantId;
private string $clientId;
private string $clientSecret;
private string $redirectUri;
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
public function __construct()
{
$this->tenantId = config('services.microsoft.tenant_id');
$this->clientId = config('services.microsoft.client_id');
$this->clientSecret = config('services.microsoft.client_secret');
$this->redirectUri = route('outlook.callback');
}
// ── Test page ─────────────────────────────────────────────────────────────
public function index(): View
{
$token = auth()->user()?->outlookToken;
$connected = $token && ! $token->isExpired();
$outlookUrl = null;
if ($connected) {
// Silent redirect URL — opens Outlook web with the user already signed in
// via SSO cookie / session; no token is passed in the URL (not safe).
// Instead we redirect to Outlook and the browser SSO session handles it.
$outlookUrl = 'https://outlook.office.com/mail/';
}
return view('test.outlook', compact('connected', 'outlookUrl', 'token'));
}
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
public function connect(): RedirectResponse
{
$state = Str::random(40);
session(['oauth_state' => $state]);
$query = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'response_mode' => 'query',
'scope' => self::SCOPES,
'state' => $state,
// prompt=none tries silent login first; falls back to login page if needed
'prompt' => 'select_account',
]);
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
}
// ── Step 2: Handle callback, exchange code for tokens ────────────────────
public function callback(Request $request): RedirectResponse
{
// CSRF state check
if ($request->state !== session('oauth_state')) {
return redirect()->route('outlook.test')->with('error', 'Invalid OAuth state. Please try again.');
}
if ($request->has('error')) {
return redirect()->route('outlook.test')->with('error', $request->error_description ?? 'Microsoft login failed.');
}
// Exchange authorization code for tokens
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $request->code,
'redirect_uri' => $this->redirectUri,
'grant_type' => 'authorization_code',
]
);
if ($response->failed()) {
return redirect()->route('outlook.test')->with('error', 'Failed to retrieve tokens from Microsoft.');
}
$data = $response->json();
// Persist token for this user (upsert so re-connecting just updates)
OutlookToken::updateOrCreate(
['user_id' => auth()->id()],
[
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? null,
'token_type' => $data['token_type'] ?? 'Bearer',
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
]
);
return redirect()->route('outlook.test')->with('success', 'Outlook connected successfully!');
}
// ── Open Outlook (refresh token silently if expired) ─────────────────────
public function openOutlook(): RedirectResponse
{
$token = auth()->user()?->outlookToken;
if (! $token) {
return redirect()->route('outlook.connect');
}
// If token is expired, try to refresh silently
if ($token->isExpired()) {
$refreshed = $this->refreshToken($token);
if (! $refreshed) {
// Refresh failed — force re-login
$token->delete();
return redirect()->route('outlook.connect');
}
}
// Redirect to Outlook Web — SSO session handles silent sign-in
return redirect('https://outlook.office.com/mail/');
}
// ── Disconnect ────────────────────────────────────────────────────────────
public function disconnect(): RedirectResponse
{
auth()->user()?->outlookToken?->delete();
return redirect()->route('outlook.test')->with('success', 'Outlook disconnected.');
}
// ── Internal: Refresh access token using refresh_token ───────────────────
private function refreshToken(OutlookToken $token): bool
{
if (! $token->refresh_token) {
return false;
}
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'refresh_token' => $token->refresh_token,
'grant_type' => 'refresh_token',
'scope' => self::SCOPES,
]
);
if ($response->failed()) {
return false;
}
$data = $response->json();
$token->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
]);
return true;
}
}

View File

@ -52,14 +52,17 @@ public function rules(): array
];
$protocol = ConnectionProtocol::query()->find($this->protocolId);
if ($protocol && ConnectionProtocolEnum::SAML->value === mb_strtolower($protocol->name)) {
$rules['samlEntityId'] = 'required|string|min:3';
$rules['samlAcsUrl'] = 'required|url';
$rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified';
$rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id';
$rules['samlAttributes'] = 'array';
$rules['samlAttributes.*.saml_name'] = 'required|string|min:1';
$rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id';
if ($protocol) {
$protocolName = mb_strtolower($protocol->name);
if (ConnectionProtocolEnum::SAML->value === $protocolName) {
$rules['samlEntityId'] = 'required|string|min:3';
$rules['samlAcsUrl'] = 'required|url';
$rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified';
$rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id';
$rules['samlAttributes'] = 'array';
$rules['samlAttributes.*.saml_name'] = 'required|string|min:1';
$rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id';
}
}
return $rules;

63
app/Models/OauthToken.php Normal file
View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\{Builder, Model};
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $user_id
* @property string $provider
* @property string $access_token
* @property string|null $refresh_token
* @property string $token_type
* @property string|null $scopes
* @property \Illuminate\Support\Carbon $expires_at
*/
class OauthToken extends Model
{
protected $table = 'oauth_tokens';
protected $fillable = [
'user_id',
'provider',
'access_token',
'refresh_token',
'token_type',
'scopes',
'expires_at',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
/**
* @param Builder<OauthToken> $query
*
* @return Builder<OauthToken>
*/
public function scopeForProvider(Builder $query, string $provider): Builder
{
return $query->where('provider', $provider);
}
}

View File

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OutlookToken extends Model
{
protected $fillable = [
'user_id',
'access_token',
'refresh_token',
'token_type',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
}

View File

@ -8,13 +8,13 @@
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\{Fillable, Hidden};
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable(['name', 'email', 'password', 'immutable_id'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
@ -62,8 +62,19 @@ protected function casts(): array
];
}
public function outlookToken(): HasOne
/**
* @return HasMany<OauthToken, $this>
*/
public function oauthTokens(): HasMany
{
return $this->hasOne(OutlookToken::class);
return $this->hasMany(OauthToken::class);
}
/**
* Get the OAuth token for a specific provider.
*/
public function oauthToken(string $provider): ?OauthToken
{
return $this->oauthTokens()->forProvider($provider)->first();
}
}

View File

@ -147,8 +147,11 @@ public function searchAppsForSelect(string $search = ''): Collection
if (auth()->check()) {
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
$query->whereHas('roles', function ($q) use ($userPriority): void {
$q->where('priority', '>', $userPriority);
$query->where(function ($q) use ($userPriority): void {
$q->whereDoesntHave('roles')
->orWhereHas('roles', function ($q2) use ($userPriority): void {
$q2->where('priority', '>', $userPriority);
});
});
}
@ -172,8 +175,11 @@ public function getAllAppIds(): array
if (auth()->check()) {
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
$query->whereHas('roles', function ($q) use ($userPriority): void {
$q->where('priority', '>', $userPriority);
$query->where(function ($q) use ($userPriority): void {
$q->whereDoesntHave('roles')
->orWhereHas('roles', function ($q2) use ($userPriority): void {
$q2->where('priority', '>', $userPriority);
});
});
}

View File

@ -21,7 +21,7 @@ class UserAppAccessService
*/
public function getActiveServices(User $user): Collection
{
$user->loadMissing(['roles.apps', 'roles.permissions']);
$user->loadMissing(['roles.apps.protocol', 'roles.apps.provider', 'roles.permissions']);
$restrictedPermissions = $this->getRestrictedPermissions($user);
@ -123,6 +123,9 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str
$existing = $appsMap->get($app->id);
$duration = Carbon::parse($durationStr);
$protocolName = $app->protocol?->name ? mb_strtolower($app->protocol->name) : null;
$providerSlug = $app->provider?->slug ? mb_strtolower($app->provider->slug) : null;
if (! $existing || Carbon::parse($existing->duration)->lt($duration)) {
$appsMap->put($app->id, new ServiceAppDto(
id: $app->id,
@ -131,6 +134,8 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str
duration: $durationStr,
days_remaining: $daysRemaining,
is_warning: $daysRemaining <= 3,
protocol_name: $protocolName,
provider_slug: $providerSlug,
));
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_tokens', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('provider')->index();
$table->text('access_token');
$table->text('refresh_token')->nullable();
$table->string('token_type')->default('Bearer');
$table->text('scopes')->nullable();
$table->timestamp('expires_at');
$table->timestamps();
$table->unique(['user_id', 'provider']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_tokens');
}
};

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
public function up(): void
{
Schema::dropIfExists('outlook_tokens');
}
public function down(): void
{
Schema::create('outlook_tokens', function ($table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('access_token');
$table->text('refresh_token')->nullable();
$table->string('token_type')->default('Bearer');
$table->timestamp('expires_at');
$table->timestamps();
$table->unique('user_id');
});
}
};

View File

@ -14,7 +14,6 @@
use App\Services\ConnectionStatusService;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Lazy;
use Livewire\Component;
use Livewire\Attributes\Title;
use Mary\Traits\Toast;
@ -23,8 +22,7 @@
/**
* @property $protocols
*/
new #[Lazy]
#[Title("Edit a connected app")]
new #[Title("Edit a connected app")]
class extends Component {
use HandlesOperations;

View File

@ -1,14 +1,18 @@
<?php
use App\Models\OauthToken;
use App\Services\UserAppAccessService;
use Illuminate\Support\Collection;
use Livewire\Attributes\{Computed, Layout, Title};
use Livewire\Component;
use Mary\Traits\Toast;
new
#[Layout('layouts.app.sidebar')]
#[Title('Dashboard')]
class extends Component {
use Toast;
private UserAppAccessService $service;
public function boot(UserAppAccessService $service): void
@ -27,6 +31,15 @@ public function mount(): void
->event('dashboard_access')
->log('User accessed their assigned services dashboard.');
}
// Show toast from OAuth redirect flash data
if (session('entra_success')) {
$this->success(session('entra_success'));
}
if (session('entra_error')) {
$this->error(session('entra_error'));
}
}
#[Computed]
@ -40,6 +53,20 @@ public function services(): Collection
return $this->service->getActiveServices($user);
}
#[Computed]
public function entraToken(): ?OauthToken
{
return auth()->user()?->oauthToken('microsoft');
}
#[Computed]
public function isEntraConnected(): bool
{
$token = $this->entraToken();
return $token !== null && !$token->isExpired();
}
};
?>
@ -114,6 +141,65 @@ class="w-3.5 h-3.5 inline animate-pulse text-amber-600 dark:text-amber-400"/>
<p class="text-xs text-gray-400 dark:text-gray-500 font-mono tracking-wider uppercase">
{{ $service->slug }}
</p>
@if($service->protocol_name === 'url' && $service->provider_slug === 'azure-fd')
<div
class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Microsoft Integration</span>
@if($this->isEntraConnected)
<x-mary-badge
class="badge-soft badge-success text-[10px] font-semibold px-2 py-0.5">
Connected
</x-mary-badge>
@else
<x-mary-badge
class="badge-soft badge-neutral text-[10px] font-semibold px-2 py-0.5">
Not Connected
</x-mary-badge>
@endif
</div>
@if($this->isEntraConnected && $this->entraToken)
<div class="text-[10px] text-gray-400 flex items-center gap-1.5">
<x-mary-icon name="lucide.clock" class="w-3.5 h-3.5"/>
Expires {{ $this->entraToken->expires_at->diffForHumans() }}
</div>
@endif
<div class="flex items-center gap-2">
@if($this->isEntraConnected)
<x-mary-button
external
icon="lucide.external-link"
:link="route('entra.open')"
class="btn-xs btn-soft btn-primary flex-1"
label="Open Outlook"
/>
<form method="POST" action="{{ route('entra.disconnect') }}"
class="inline">
@csrf
@method('DELETE')
<x-mary-button
icon="lucide.unplug"
type="submit"
class="btn-xs btn-soft btn-error"
tooltip="Disconnect"
/>
</form>
@else
<x-mary-button
no-wire-navigate
icon="lucide.plug"
:link="route('entra.connect')"
class="btn-xs btn-soft btn-primary flex-1"
label="Connect"
/>
@endif
</div>
</div>
@endif
</div>
<div class="pt-4 border-t border-gray-200 flex items-center justify-between gap-4">
@ -137,11 +223,31 @@ class="btn-sm btn-circle btn-soft btn-warning"
/>
@endif
<x-mary-button
icon="lucide.external-link"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@if($service->provider_slug === 'azure-fd')
@if($this->isEntraConnected)
<x-mary-button
external
icon="lucide.external-link"
:link="route('entra.open')"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@else
<x-mary-button
no-wire-navigate
icon="lucide.external-link"
:link="route('entra.connect')"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@endif
@else
<x-mary-button
icon="lucide.external-link"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@endif
</div>
</div>
</div>

View File

@ -1,103 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Outlook Connect Test</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-50 flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-lg p-10 w-full max-w-md text-center space-y-6">
{{-- Logo --}}
<div class="flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-14 h-14" viewBox="0 0 48 48">
<rect width="26" height="26" x="2" y="11" fill="#0078d4" rx="2"/>
<path fill="#fff" d="M15 18a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm0 12a5 5 0 1 1 0-10 5 5 0 0 1 0 10z"/>
<path fill="#0078d4" d="M28 11h16a2 2 0 0 1 2 2v22a2 2 0 0 1-2 2H28V11z"/>
<path fill="#fff" d="M30 17h14v2H30zm0 4h14v2H30zm0 4h14v2H30zm0 4h8v2h-8z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-800">Outlook Integration</h1>
<p class="text-gray-500 text-sm mt-1">Connect your Microsoft Outlook account</p>
</div>
{{-- Flash messages --}}
@if(session('success'))
<div class="bg-green-50 border border-green-200 text-green-700 rounded-lg px-4 py-3 text-sm">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{{ session('error') }}
</div>
@endif
{{-- Status card --}}
<div class="bg-gray-50 rounded-xl px-6 py-4 text-sm text-left space-y-2">
<div class="flex items-center justify-between">
<span class="text-gray-600 font-medium">Status</span>
@if($connected)
<span class="inline-flex items-center gap-1 text-green-600 font-semibold">
<span class="w-2 h-2 rounded-full bg-green-500 inline-block"></span> Connected
</span>
@else
<span class="inline-flex items-center gap-1 text-gray-400 font-semibold">
<span class="w-2 h-2 rounded-full bg-gray-300 inline-block"></span> Not connected
</span>
@endif
</div>
@if($connected && $token)
<div class="flex items-center justify-between">
<span class="text-gray-600">Token expires</span>
<span class="text-gray-700 font-mono text-xs">{{ $token->expires_at->diffForHumans() }}</span>
</div>
@endif
</div>
{{-- Action buttons --}}
@if($connected)
{{-- Open Outlook --}}
<a href="{{ route('outlook.open') }}"
class="flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-xl transition duration-150">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
Open Outlook
</a>
{{-- Disconnect --}}
<form method="POST" action="{{ route('outlook.disconnect') }}">
@csrf
@method('DELETE')
<button type="submit"
class="w-full text-sm text-red-500 hover:text-red-700 underline transition">
Disconnect Outlook
</button>
</form>
@else
{{-- Connect button --}}
<a href="{{ route('outlook.connect') }}"
class="flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-xl transition duration-150">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M21.35 11.1H12.18V13.83H18.69C18.36 17.64 15.19 19.27 12.19 19.27C8.36 19.27 5 16.25 5 12C5 7.9 8.2 4.73 12.2 4.73C15.29 4.73 17.1 6.7 17.1 6.7L19 4.72C19 4.72 16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12C2.03 17.05 6.16 22 12.25 22C17.6 22 21.5 18.33 21.5 12.91C21.5 11.76 21.35 11.1 21.35 11.1Z"/>
</svg>
Connect to Outlook
</a>
<p class="text-xs text-gray-400">
You'll be redirected to Microsoft login. After connecting, you won't need to log in again.
</p>
@endif
</div>
</body>
</html>

View File

@ -8,10 +8,10 @@
PermissionPermissionEnum,
RoleAndAppsPermissionEnum,
UserPermissionEnum};
use App\Http\Controllers\EntraController;
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
use Illuminate\Support\Facades\Route;
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
use App\Http\Controllers\OutlookController;
Route::redirect('/', '/dashboard')->name('home');
@ -82,22 +82,18 @@
});
});
Route::middleware(['auth', 'verified'])->prefix('test')->name('outlook.')->group(function (): void {
// Test page with Connect button
Route::get('outlook', [OutlookController::class, 'index'])->name('test');
Route::middleware(['auth', 'verified'])->prefix('entra')->name('entra.')->group(function (): void {
// Step 1: Kick off OAuth flow
Route::get('outlook/connect', [OutlookController::class, 'connect'])->name('connect');
Route::get('connect', [EntraController::class, 'connect'])->name('connect');
// Step 2: Microsoft redirects back here with auth code
Route::get('outlook/callback', [OutlookController::class, 'callback'])->name('callback');
Route::get('callback', [EntraController::class, 'callback'])->name('callback');
// Open Outlook (refreshes token silently if expired)
Route::get('outlook/open', [OutlookController::class, 'openOutlook'])->name('open');
// Open Entra (refreshes token silently if expired)
Route::get('open', [EntraController::class, 'openEntra'])->name('open');
// Disconnect / revoke saved token
Route::delete('outlook/disconnect', [OutlookController::class, 'disconnect'])->name('disconnect');
Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect');
});
require __DIR__.'/settings.php';

View File

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
use App\Models\{OauthToken, User};
use Illuminate\Support\Facades\Http;
function assignAzureFdAccess(User $user): void
{
$protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->first();
if (! $protocol) {
$protocol = new App\Models\ConnectionProtocol();
$protocol->name = 'url';
$protocol->save();
}
$provider = App\Models\ConnectionProvider::query()->where('slug', 'azure-fd')->first();
if (! $provider) {
$provider = new App\Models\ConnectionProvider();
$provider->name = 'Azure FD';
$provider->slug = 'azure-fd';
$provider->save();
}
$status = App\Models\ConnectionStatus::query()->where('name', 'connected')->first();
if (! $status) {
$status = new App\Models\ConnectionStatus();
$status->name = 'connected';
$status->save();
}
$app = App\Models\ConnectedApp::query()->create([
'name' => 'Azure FD Portal',
'slug' => 'azure-fd-portal',
'connection_protocol_id' => $protocol->id,
'connection_provider_id' => $provider->id,
'connection_status_id' => $status->id,
'settings' => [
'url' => [
'redirect_url' => 'https://azure.microsoft.com',
],
],
]);
$role = App\Models\Role::findOrCreate('Azure User');
$role->update(['priority' => 10]);
$role->apps()->attach($app->id, ['duration' => now()->addYear()->toDateTimeString()]);
$user->assignRole($role);
}
it('redirects unauthenticated users to login when connecting', function (): void {
$this->get(route('entra.connect'))
->assertRedirect(route('login'));
});
it('returns 403 forbidden if user does not have permission for azure-fd app', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('entra.connect'))
->assertStatus(403);
});
it('redirects to Microsoft login on connect when authorized', function (): void {
$user = User::factory()->create();
assignAzureFdAccess($user);
$response = $this->actingAs($user)
->get(route('entra.connect'));
$response->assertRedirectContains('login.microsoftonline.com');
$response->assertRedirectContains('oauth2/v2.0/authorize');
});
it('stores token and redirects to dashboard on successful callback when authorized', function (): void {
$user = User::factory()->create();
assignAzureFdAccess($user);
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'fake-access-token',
'refresh_token' => 'fake-refresh-token',
'token_type' => 'Bearer',
'expires_in' => 3600,
]),
]);
$state = 'test-state-value';
$response = $this->actingAs($user)
->withSession(['oauth_state' => $state])
->get(route('entra.callback', [
'code' => 'fake-auth-code',
'state' => $state,
]));
$response->assertRedirect(route('dashboard.user'));
$response->assertSessionHas('entra_success', 'Microsoft connected successfully!');
$this->assertDatabaseHas('oauth_tokens', [
'user_id' => $user->id,
'provider' => 'microsoft',
'token_type' => 'Bearer',
]);
});
it('redirects to dashboard with error on invalid state when authorized', function (): void {
$user = User::factory()->create();
assignAzureFdAccess($user);
$response = $this->actingAs($user)
->withSession(['oauth_state' => 'correct-state'])
->get(route('entra.callback', [
'code' => 'fake-code',
'state' => 'wrong-state',
]));
$response->assertRedirect(route('dashboard.user'));
$response->assertSessionHas('entra_error');
});
it('redirects to dashboard with error when Microsoft returns error and authorized', function (): void {
$user = User::factory()->create();
assignAzureFdAccess($user);
$state = 'test-state';
$response = $this->actingAs($user)
->withSession(['oauth_state' => $state])
->get(route('entra.callback', [
'error' => 'access_denied',
'error_description' => 'User cancelled the request',
'state' => $state,
]));
$response->assertRedirect(route('dashboard.user'));
$response->assertSessionHas('entra_error', 'User cancelled the request');
});
it('disconnects and removes the token when authorized', function (): void {
$user = User::factory()->create();
assignAzureFdAccess($user);
OauthToken::create([
'user_id' => $user->id,
'provider' => 'microsoft',
'access_token' => 'fake-token',
'refresh_token' => 'fake-refresh',
'token_type' => 'Bearer',
'expires_at' => now()->addHour(),
]);
$response = $this->actingAs($user)
->delete(route('entra.disconnect'));
$response->assertRedirect(route('dashboard.user'));
$response->assertSessionHas('entra_success', 'Microsoft disconnected.');
$this->assertDatabaseMissing('oauth_tokens', [
'user_id' => $user->id,
'provider' => 'microsoft',
]);
});
it('updates existing token on re-connect when authorized', function (): void {
$user = User::factory()->create();
assignAzureFdAccess($user);
OauthToken::create([
'user_id' => $user->id,
'provider' => 'microsoft',
'access_token' => 'old-token',
'refresh_token' => 'old-refresh',
'token_type' => 'Bearer',
'expires_at' => now()->subHour(),
]);
Http::fake([
'login.microsoftonline.com/*' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'token_type' => 'Bearer',
'expires_in' => 3600,
]),
]);
$state = 'reconnect-state';
$this->actingAs($user)
->withSession(['oauth_state' => $state])
->get(route('entra.callback', [
'code' => 'new-auth-code',
'state' => $state,
]));
expect(OauthToken::where('user_id', $user->id)->where('provider', 'microsoft')->count())->toBe(1);
$token = $user->oauthToken('microsoft');
expect($token->access_token)->toBe('new-access-token');
expect($token->refresh_token)->toBe('new-refresh-token');
});

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionProvider, ConnectionStatus, User};
use App\Services\UserAppAccessService;
use Livewire\Livewire;
beforeEach(function (): void {
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
$this->urlProtocol = ConnectionProtocol::query()->where('name', 'url')->firstOrFail();
$this->provider = ConnectionProvider::query()->where('slug', 'azure-fd')->firstOrFail();
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
});
it('successfully creates a connected app with URL protocol', function (): void {
$admin = User::factory()->create();
$admin->assignRole(App\Models\Role::findOrCreate('Admin'));
$this->actingAs($admin);
Livewire::test('pages::connected-apps.create')
->set('form.name', 'Azure Portal')
->set('form.protocolId', $this->urlProtocol->id)
->set('form.providerId', $this->provider->id)
->set('form.statusId', $this->status->id)
->call('save');
$this->assertDatabaseHas('connected_apps', [
'name' => 'Azure Portal',
'connection_protocol_id' => $this->urlProtocol->id,
]);
});
it('successfully edits a connected app with URL protocol', function (): void {
$admin = User::factory()->create();
$admin->assignRole(App\Models\Role::findOrCreate('Admin'));
$this->actingAs($admin);
$app = ConnectedApp::query()->create([
'name' => 'Azure Portal',
'slug' => 'azure-portal',
'connection_protocol_id' => $this->urlProtocol->id,
'connection_provider_id' => $this->provider->id,
'connection_status_id' => $this->status->id,
]);
Livewire::test('pages::connected-apps.edit', ['id' => $app->id])
->set('form.name', 'Updated Azure Portal')
->call('save');
$app->refresh();
expect($app->name)->toBe('Updated Azure Portal');
});
it('maps URL protocol to ServiceAppDto in UserAppAccessService', function (): void {
$user = User::factory()->create();
$app = ConnectedApp::query()->create([
'name' => 'Azure Portal',
'slug' => 'azure-portal',
'connection_protocol_id' => $this->urlProtocol->id,
'connection_provider_id' => $this->provider->id,
'connection_status_id' => $this->status->id,
]);
$role = App\Models\Role::findOrCreate('Standard User');
$role->update(['priority' => 10]);
$role->apps()->attach($app->id, ['duration' => now()->addYear()->toDateTimeString()]);
$user->assignRole($role);
$service = app(UserAppAccessService::class);
$activeServices = $service->getActiveServices($user);
expect($activeServices->count())->toBe(1);
$dto = $activeServices->first();
expect($dto->protocol_name)->toBe('url')
->and($dto->provider_slug)->toBe('azure-fd');
});
it('shows newly created connected apps with no roles in searchAppsForSelect dropdown', function (): void {
$admin = User::factory()->create();
$role = App\Models\Role::findOrCreate('Admin');
$role->update(['priority' => 1]);
$admin->assignRole($role);
$this->actingAs($admin);
$app = ConnectedApp::query()->create([
'name' => 'Brand New App',
'slug' => 'brand-new-app',
'connection_protocol_id' => $this->urlProtocol->id,
'connection_provider_id' => $this->provider->id,
'connection_status_id' => $this->status->id,
]);
$appRoleService = app(App\Services\AppRoleService::class);
$apps = $appRoleService->searchAppsForSelect('');
expect($apps->pluck('id')->toArray())->toContain($app->id);
});