Feat: introduce Microsoft Entra ID federation configuration

- Added `MicrosoftGraphCredentialsData` and `FederatedIdpMfaBehaviorEnum` for managing Entra ID integration.
- Implemented `MicrosoftGraphService` to handle federation setup, domain management, and token authentication.
- Created UI for managing Microsoft Graph Federation, including connection and disconnection flows.
- Extended tests to validate Microsoft Graph Federation functionality.
This commit is contained in:
= 2026-05-28 12:43:17 +00:00
parent f61acb8907
commit df4a84b970
10 changed files with 1176 additions and 6 deletions

View File

@ -63,3 +63,12 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Microsoft Graph API credentials for Domain Federation
MICROSOFT_GRAPH_TENANT_ID=
MICROSOFT_GRAPH_CLIENT_ID=
MICROSOFT_GRAPH_CLIENT_SECRET=
MICROSOFT_GRAPH_DOMAIN_NAME=
MICROSOFT_GRAPH_DISPLAY_NAME="Company SSO"
MICROSOFT_GRAPH_MFA_BEHAVIOR="acceptIfMfaDoneByFederatedIdp"

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Data\MicrosoftGraph;
use App\Enums\FederatedIdpMfaBehaviorEnum;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapOutputName(SnakeCaseMapper::class)]
final class MicrosoftGraphCredentialsData extends Data
{
public function __construct(
public string $tenantId,
public string $clientId,
public string $clientSecret,
public string $domainName,
public string $displayName = 'Company SSO',
public FederatedIdpMfaBehaviorEnum $mfaBehavior = FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp,
) {}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Concerns\EnumValuesAsArray;
/**
* Entra ID Federated IdP MFA behavior configuration values.
*/
enum FederatedIdpMfaBehaviorEnum: string
{
use EnumValuesAsArray;
case AcceptIfMfaDoneByFederatedIdp = 'acceptIfMfaDoneByFederatedIdp';
case EnforceMfaByFederatedIdp = 'enforceMfaByFederatedIdp';
case RejectMfaByFederatedIdp = 'rejectMfaByFederatedIdp';
}

View File

@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\MicrosoftGraph\MicrosoftGraphCredentialsData;
use Exception;
use Illuminate\Support\Facades\Http;
/**
* Service to manage Microsoft Graph API domain federation configuration.
*/
final class MicrosoftGraphService
{
/**
* Get an access token using Client Credentials grant flow.
*
* @return array{token: ?string, transaction: array}
*/
public function getAccessToken(MicrosoftGraphCredentialsData $credentials): array
{
$url = "https://login.microsoftonline.com/{$credentials->tenantId}/oauth2/v2.0/token";
$body = [
'client_id' => $credentials->clientId,
'client_secret' => $credentials->clientSecret,
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
];
$requestHeaders = [
'Content-Type' => 'application/x-www-form-urlencoded',
];
try {
$response = Http::asForm()->post($url, $body);
$transaction = [
'name' => 'OAuth 2.0 Get Access Token',
'url' => $url,
'method' => 'POST',
'request_headers' => $requestHeaders,
'request_body' => collect($body)->except('client_secret')->put('client_secret', '********')->toArray(),
'status' => $response->status(),
'response_headers' => $response->headers(),
'response_body' => $response->json(),
];
if ($response->successful()) {
return [
'token' => $response->json('access_token'),
'transaction' => $transaction,
];
}
} catch (Exception $e) {
$transaction = [
'name' => 'OAuth 2.0 Get Access Token',
'url' => $url,
'method' => 'POST',
'request_headers' => $requestHeaders,
'request_body' => collect($body)->except('client_secret')->put('client_secret', '********')->toArray(),
'status' => 'ERROR',
'response_body' => ['error' => $e->getMessage()],
];
}
return [
'token' => null,
'transaction' => $transaction,
];
}
/**
* Retrieve federation configuration for the domain.
*
* @return array{status: string, message?: string, config: ?array, transactions: array}
*/
public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): array
{
$auth = $this->getAccessToken($credentials);
$transactions = [$auth['transaction']];
if (! $auth['token']) {
return [
'status' => 'error',
'message' => 'Authentication with Microsoft Graph failed.',
'config' => null,
'transactions' => $transactions,
];
}
$url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration";
$headers = [
'Authorization' => 'Bearer ********',
'Content-Type' => 'application/json',
];
try {
$response = Http::withToken($auth['token'])->get($url);
$transactions[] = [
'name' => 'Get Federation Configuration',
'url' => $url,
'method' => 'GET',
'request_headers' => $headers,
'request_body' => null,
'status' => $response->status(),
'response_headers' => $response->headers(),
'response_body' => $response->json(),
];
if ($response->successful()) {
/** @var array $value */
$value = $response->json('value', []);
return [
'status' => 'success',
'config' => ! empty($value) ? $value[0] : null,
'transactions' => $transactions,
];
}
/** @var string $errorMsg */
$errorMsg = $response->json('error.message', 'Failed to retrieve federation config.');
return [
'status' => 'error',
'message' => $errorMsg,
'config' => null,
'transactions' => $transactions,
];
} catch (Exception $e) {
$transactions[] = [
'name' => 'Get Federation Configuration',
'url' => $url,
'method' => 'GET',
'request_headers' => $headers,
'request_body' => null,
'status' => 'ERROR',
'response_body' => ['error' => $e->getMessage()],
];
return [
'status' => 'error',
'message' => $e->getMessage(),
'config' => null,
'transactions' => $transactions,
];
}
}
/**
* Connect/Federate the domain.
*
* @return array{status: string, message?: string, config?: ?array, transactions: array}
*/
public function connectFederation(
MicrosoftGraphCredentialsData $credentials,
string $certPem,
string $issuerUri,
string $passiveSignInUri,
string $activeSignInUri,
string $signOutUri
): array {
$auth = $this->getAccessToken($credentials);
$transactions = [$auth['transaction']];
if (! $auth['token']) {
return [
'status' => 'error',
'message' => 'Authentication with Microsoft Graph failed.',
'transactions' => $transactions,
];
}
// Check and delete existing configuration to ensure a clean setup
$checkUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration";
try {
$checkRes = Http::withToken($auth['token'])->get($checkUrl);
$transactions[] = [
'name' => 'Check Existing Federation Configuration',
'url' => $checkUrl,
'method' => 'GET',
'status' => $checkRes->status(),
'response_body' => $checkRes->json(),
];
if ($checkRes->successful()) {
/** @var array $configs */
$configs = $checkRes->json('value', []);
if (! empty($configs)) {
/** @var string $existingId */
$existingId = $configs[0]['id'];
$deleteUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$existingId}";
$deleteRes = Http::withToken($auth['token'])->delete($deleteUrl);
$transactions[] = [
'name' => 'Delete Pre-existing Federation Configuration',
'url' => $deleteUrl,
'method' => 'DELETE',
'status' => $deleteRes->status(),
'response_body' => $deleteRes->json() ?: ['message' => 'Deleted successfully'],
];
if (! $deleteRes->successful()) {
return [
'status' => 'error',
'message' => 'Failed to delete pre-existing federation configuration.',
'transactions' => $transactions,
];
}
}
}
} catch (Exception $e) {
// Suppress exception and proceed
}
// Prepare the signing certificate PEM string
$signingCertificateClean = str_replace(
['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----', "\r", "\n", ' '],
'',
$certPem
);
$url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration";
$body = [
'@odata.type' => '#microsoft.graph.internalDomainFederation',
'displayName' => $credentials->displayName,
'issuerUri' => $issuerUri,
'signingCertificate' => $signingCertificateClean,
'passiveSignInUri' => $passiveSignInUri,
'preferredAuthenticationProtocol' => 'saml',
'activeSignInUri' => $activeSignInUri,
'signOutUri' => $signOutUri,
'promptLoginBehavior' => 'nativeSupport',
'isSignedAuthenticationRequestRequired' => false,
'federatedIdpMfaBehavior' => $credentials->mfaBehavior->value,
];
$headers = [
'Authorization' => 'Bearer ********',
'Content-Type' => 'application/json',
];
try {
$response = Http::withToken($auth['token'])->post($url, $body);
$transactions[] = [
'name' => 'Create Federation Configuration',
'url' => $url,
'method' => 'POST',
'request_headers' => $headers,
'request_body' => $body,
'status' => $response->status(),
'response_headers' => $response->headers(),
'response_body' => $response->json(),
];
if ($response->successful()) {
return [
'status' => 'success',
'config' => $response->json(),
'transactions' => $transactions,
];
}
/** @var string $errorMsg */
$errorMsg = $response->json('error.message', 'Failed to create federation configuration.');
return [
'status' => 'error',
'message' => $errorMsg,
'transactions' => $transactions,
];
} catch (Exception $e) {
$transactions[] = [
'name' => 'Create Federation Configuration',
'url' => $url,
'method' => 'POST',
'request_headers' => $headers,
'request_body' => $body,
'status' => 'ERROR',
'response_body' => ['error' => $e->getMessage()],
];
return [
'status' => 'error',
'message' => $e->getMessage(),
'transactions' => $transactions,
];
}
}
/**
* Disconnect/Remove federation configuration.
*
* @return array{status: string, message?: string, transactions: array}
*/
public function disconnectFederation(MicrosoftGraphCredentialsData $credentials, string $federationId): array
{
$auth = $this->getAccessToken($credentials);
$transactions = [$auth['transaction']];
if (! $auth['token']) {
return [
'status' => 'error',
'message' => 'Authentication with Microsoft Graph failed.',
'transactions' => $transactions,
];
}
$url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$federationId}";
$headers = [
'Authorization' => 'Bearer ********',
];
try {
$response = Http::withToken($auth['token'])->delete($url);
$transactions[] = [
'name' => 'Delete Federation Configuration',
'url' => $url,
'method' => 'DELETE',
'request_headers' => $headers,
'request_body' => null,
'status' => $response->status(),
'response_headers' => $response->headers(),
'response_body' => $response->json() ?: ['message' => 'Federation removed successfully. Domain returned to Managed.'],
];
if ($response->successful()) {
return [
'status' => 'success',
'transactions' => $transactions,
];
}
/** @var string $errorMsg */
$errorMsg = $response->json('error.message', 'Failed to remove federation configuration.');
return [
'status' => 'error',
'message' => $errorMsg,
'transactions' => $transactions,
];
} catch (Exception $e) {
$transactions[] = [
'name' => 'Delete Federation Configuration',
'url' => $url,
'method' => 'DELETE',
'request_headers' => $headers,
'request_body' => null,
'status' => 'ERROR',
'response_body' => ['error' => $e->getMessage()],
];
return [
'status' => 'error',
'message' => $e->getMessage(),
'transactions' => $transactions,
];
}
}
}

View File

@ -1,9 +1,5 @@
{ {
"agents": [ "agents": ["claude_code", "copilot", "opencode"],
"claude_code",
"copilot",
"opencode"
],
"cloud": false, "cloud": false,
"guidelines": true, "guidelines": true,
"mcp": true, "mcp": true,
@ -12,7 +8,6 @@
"skills": [ "skills": [
"fortify-development", "fortify-development",
"laravel-best-practices", "laravel-best-practices",
"fluxui-development",
"livewire-development", "livewire-development",
"pest-testing", "pest-testing",
"tailwindcss-development" "tailwindcss-development"

View File

@ -37,4 +37,13 @@
], ],
], ],
'microsoft_graph' => [
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
'domain_name' => env('MICROSOFT_GRAPH_DOMAIN_NAME'),
'display_name' => env('MICROSOFT_GRAPH_DISPLAY_NAME', 'Company SSO'),
'mfa_behavior' => env('MICROSOFT_GRAPH_MFA_BEHAVIOR', 'acceptIfMfaDoneByFederatedIdp'),
],
]; ];

View File

@ -56,6 +56,12 @@ public function navItems(): DataCollection
route: 'apps.connectionProviders.index', route: 'apps.connectionProviders.index',
active_pattern: 'apps.connectionProviders.*', active_pattern: 'apps.connectionProviders.*',
permission: ConnectionProviderPermissionEnum::Access permission: ConnectionProviderPermissionEnum::Access
),
new NavSubItemData(
label: 'Microsoft Graph',
route: 'apps.microsoft-federation',
active_pattern: 'apps.microsoft-federation',
permission: ConnectionProviderPermissionEnum::Access
) )
], DataCollection::class) ], DataCollection::class)
), ),

View File

@ -0,0 +1,587 @@
<?php
declare(strict_types=1);
use App\Concerns\HandlesOperations;
use App\Data\MicrosoftGraph\MicrosoftGraphCredentialsData;
use App\Enums\ConnectionStatusEnum;
use App\Enums\FederatedIdpMfaBehaviorEnum;
use App\Services\MicrosoftGraphService;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title("Microsoft Graph Federation")]
class extends Component {
use HandlesOperations;
protected MicrosoftGraphService $service;
public bool $credentialsLoaded = false;
public bool $certificateLoaded = false;
public string $tenantId = '';
public string $clientId = '';
public string $clientSecret = '';
public string $domainName = '';
public string $displayName = '';
public FederatedIdpMfaBehaviorEnum $mfaBehavior;
public ConnectionStatusEnum $connectionStatus = ConnectionStatusEnum::Disconnected;
public ?string $federationId = null;
public ?array $activeFederationConfig = null;
public array $debugResponses = [];
public function boot(MicrosoftGraphService $service): void
{
$this->service = $service;
}
#[Computed]
public function mfaBehaviorOptions(): array
{
return array_map(fn($case) => [
'id' => $case->value,
'name' => match ($case) {
FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp => 'Accept if MFA Done by Federated IdP',
FederatedIdpMfaBehaviorEnum::EnforceMfaByFederatedIdp => 'Enforce MFA by Federated IdP',
FederatedIdpMfaBehaviorEnum::RejectMfaByFederatedIdp => 'Always perform MFA (Reject Federated IdP MFA)',
},
], FederatedIdpMfaBehaviorEnum::cases());
}
public function mount(): void
{
$config = config('services.microsoft_graph');
$this->tenantId = (string) ($config['tenant_id'] ?? '');
$this->clientId = (string) ($config['client_id'] ?? '');
$this->clientSecret = (string) ($config['client_secret'] ?? '');
$this->domainName = (string) ($config['domain_name'] ?? '');
$this->displayName = (string) ($config['display_name'] ?? 'Company SSO');
$this->mfaBehavior = FederatedIdpMfaBehaviorEnum::tryFrom(
(string) ($config['mfa_behavior'] ?? '')
) ?? FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp;
if (
!empty($this->tenantId) &&
!empty($this->clientId) &&
!empty($this->clientSecret) &&
!empty($this->domainName)
) {
$this->credentialsLoaded = true;
}
if (file_exists(storage_path("app/saml/idp.crt"))) {
$this->certificateLoaded = true;
}
if ($this->credentialsLoaded) {
$this->checkStatus(true);
}
}
public function checkStatus(bool $silent = false): void
{
$this->attempt(
action: function () use ($silent) {
$credentials = new MicrosoftGraphCredentialsData(
tenantId: $this->tenantId,
clientId: $this->clientId,
clientSecret: $this->clientSecret,
domainName: $this->domainName,
displayName: $this->displayName,
mfaBehavior: $this->mfaBehavior,
);
$result = $this->service->getFederationConfig($credentials);
foreach ($result["transactions"] as $tx) {
$this->debugResponses[] = array_merge($tx, [
"timestamp" => now()->format("Y-m-d H:i:s"),
]);
}
if ($result["status"] === "success") {
if ($result["config"] !== null) {
$this->connectionStatus =
ConnectionStatusEnum::Connected;
$this->federationId = $result["config"]["id"];
$this->activeFederationConfig = $result["config"];
if (!$silent) {
$this->success(
"Federation is active on Microsoft Entra ID!",
);
}
} else {
$this->connectionStatus =
ConnectionStatusEnum::Disconnected;
$this->federationId = null;
$this->activeFederationConfig = null;
if (!$silent) {
$this->success(
"Domain is managed. No federation configuration found.",
);
}
}
} else {
$this->connectionStatus = ConnectionStatusEnum::Error;
if (!$silent) {
$this->error(
$result["message"] ?? "Failed to check status.",
);
}
}
},
showSuccess: false,
);
}
public function connect(): void
{
if (!$this->certificateLoaded) {
$this->error(
"Missing public certificate! Run php artisan saml:generate-keys first.",
);
return;
}
$this->attempt(
action: function () {
$credentials = new MicrosoftGraphCredentialsData(
tenantId: $this->tenantId,
clientId: $this->clientId,
clientSecret: $this->clientSecret,
domainName: $this->domainName,
displayName: $this->displayName,
mfaBehavior: $this->mfaBehavior,
);
$certPem = file_get_contents(storage_path("app/saml/idp.crt"));
$issuerUri = route("saml.metadata");
$passiveSignInUri = route("saml.sso");
$activeSignInUri = route("saml.sso");
$signOutUri = route("home");
$result = $this->service->connectFederation(
credentials: $credentials,
certPem: $certPem,
issuerUri: $issuerUri,
passiveSignInUri: $passiveSignInUri,
activeSignInUri: $activeSignInUri,
signOutUri: $signOutUri,
);
foreach ($result["transactions"] as $tx) {
$this->debugResponses[] = array_merge($tx, [
"timestamp" => now()->format("Y-m-d H:i:s"),
]);
}
if ($result["status"] === "success") {
$this->connectionStatus = ConnectionStatusEnum::Connected;
$this->federationId = $result["config"]["id"] ?? null;
$this->activeFederationConfig = $result["config"];
$this->success(
"SAML IDP successfully federated with Microsoft Entra ID!",
);
} else {
$this->connectionStatus = ConnectionStatusEnum::Error;
$this->error(
$result["message"] ?? "Failed to connect federation.",
);
}
},
showSuccess: false,
);
}
public function disconnect(): void
{
if (empty($this->federationId)) {
$this->error("No active federation ID found. Check status first.");
return;
}
$this->attempt(
action: function () {
$credentials = new MicrosoftGraphCredentialsData(
tenantId: $this->tenantId,
clientId: $this->clientId,
clientSecret: $this->clientSecret,
domainName: $this->domainName,
displayName: $this->displayName,
mfaBehavior: $this->mfaBehavior,
);
$result = $this->service->disconnectFederation(
$credentials,
$this->federationId,
);
foreach ($result["transactions"] as $tx) {
$this->debugResponses[] = array_merge($tx, [
"timestamp" => now()->format("Y-m-d H:i:s"),
]);
}
if ($result["status"] === "success") {
$this->connectionStatus =
ConnectionStatusEnum::Disconnected;
$this->federationId = null;
$this->activeFederationConfig = null;
$this->success(
"SAML IDP disconnected from Microsoft Entra ID. Domain returned to Managed.",
);
} else {
$this->connectionStatus = ConnectionStatusEnum::Error;
$this->error(
$result["message"] ??
"Failed to disconnect federation.",
);
}
},
showSuccess: false,
);
}
public function clearDebug(): void
{
$this->debugResponses = [];
$this->success("Debug outputs cleared.");
}
};
?>
<div>
<div class="space-y-6">
<!-- Banner Alerts -->
@if(!$credentialsLoaded)
<div
class="rounded-xl border border-amber-200/50 bg-amber-50/50 p-4 shadow-sm backdrop-blur-xs flex items-start gap-x-3">
<span class="text-amber-500 mt-0.5"><x-mary-icon name="lucide.triangle-alert" class="w-5 h-5"/></span>
<div>
<h3 class="font-medium text-amber-900">Environment Configuration Required</h3>
<p class="text-sm text-amber-700 mt-1 leading-relaxed">
To enable the Microsoft Graph API integration, please configure the environment, to include
needed values.
</p>
</div>
</div>
@endif
@if($credentialsLoaded && !$certificateLoaded)
<div
class="rounded-xl border border-rose-200/50 bg-rose-50/50 p-4 shadow-sm backdrop-blur-xs flex items-start gap-x-3">
<span class="text-rose-500 mt-0.5"><x-mary-icon name="lucide.shield-alert" class="w-5 h-5"/></span>
<div>
<h3 class="font-medium text-rose-900">SAML Signing Certificate Missing</h3>
<p class="text-sm text-rose-700 mt-1">
A verified public certificate is required to sign SAML requests sent to Microsoft Entra ID. No
certificate was found.
</p>
</div>
</div>
@endif
<!-- Main Card Group -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- Left Side: Configuration details & Actions -->
<div class="xl:col-span-2 space-y-6">
<x-shared.card title="Microsoft Entra Federation Settings">
<x-slot:actions>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 font-medium">Status:</span>
@if($connectionStatus === ConnectionStatusEnum::Connected)
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-200">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Federated
</span>
@elseif($connectionStatus === ConnectionStatusEnum::Disconnected)
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-800 border border-gray-200">
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
Managed
</span>
@elseif($connectionStatus === ConnectionStatusEnum::Pending)
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
<span class="w-2 h-2 rounded-full bg-amber-500 animate-spin"></span>
Connecting...
</span>
@else
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-rose-100 text-rose-800 border border-rose-200">
<span class="w-2 h-2 rounded-full bg-rose-500"></span>
Error
</span>
@endif
</div>
</x-slot:actions>
<div class="p-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<x-mary-input label="Microsoft Entra Tenant ID" :value="$tenantId ?: 'Not Configured'"
disabled class="bg-gray-50/50" icon="lucide.key-round"/>
<span class="text-xs text-gray-400 mt-1 block">MICROSOFT_GRAPH_TENANT_ID</span>
</div>
<div>
<x-mary-input label="Application (Client) ID" :value="$clientId ?: 'Not Configured'"
disabled class="bg-gray-50/50" icon="lucide.app-window"/>
<span class="text-xs text-gray-400 mt-1 block">MICROSOFT_GRAPH_CLIENT_ID</span>
</div>
<div>
<x-mary-input label="Client Secret" value="••••••••••••••••••••••••" disabled
class="bg-gray-50/50" icon="lucide.lock"/>
<span class="text-xs text-gray-400 mt-1 block">MICROSOFT_GRAPH_CLIENT_SECRET</span>
</div>
<div>
<x-mary-input label="Verified Domain Name" :value="$domainName ?: 'Not Configured'"
disabled class="bg-gray-50/50" icon="lucide.globe"/>
<span class="text-xs text-gray-400 mt-1 block">MICROSOFT_GRAPH_DOMAIN_NAME</span>
</div>
<div>
<x-mary-input label="Federated IDP Display Name" :value="$displayName" disabled
class="bg-gray-50/50" icon="lucide.fingerprint"/>
<span class="text-xs text-gray-400 mt-1 block">MICROSOFT_GRAPH_DISPLAY_NAME</span>
</div>
<div>
<x-mary-choices
label="Federated IDP MFA Behavior"
wire:model="mfaBehavior"
:options="$this->mfaBehaviorOptions"
:single="true"
disabled
class="bg-gray-50/50 text-xs"
icon="lucide.shield-check"
/>
<span class="text-xs text-gray-400 mt-1 block">MICROSOFT_GRAPH_MFA_BEHAVIOR</span>
</div>
</div>
<!-- SAML Endpoints Preview Card -->
<div class="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-3">
<h4 class="text-sm font-semibold text-gray-700 flex items-center gap-1.5">
<x-mary-icon name="lucide.link" class="w-4 h-4 text-sky-500"/>
local Identity Provider Endpoints
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs font-mono">
<div>
<span class="text-gray-400 block font-sans font-medium text-[10px] uppercase">Issuer URI (Entity ID)</span>
<span class="text-gray-700 select-all">{{ route('saml.metadata') }}</span>
</div>
<div>
<span class="text-gray-400 block font-sans font-medium text-[10px] uppercase">Passive Sign-In Endpoint (SSO)</span>
<span class="text-gray-700 select-all">{{ route('saml.sso') }}</span>
</div>
<div>
<span class="text-gray-400 block font-sans font-medium text-[10px] uppercase">Active Sign-In Endpoint (WS-Trust)</span>
<span class="text-gray-700 select-all">{{ route('saml.sso') }}</span>
</div>
<div>
<span class="text-gray-400 block font-sans font-medium text-[10px] uppercase">Sign-Out URI</span>
<span class="text-gray-700 select-all">{{ route('home') }}</span>
</div>
</div>
</div>
<!-- Action controls -->
<div
class="flex flex-wrap items-center justify-between gap-4 border-t border-gray-100 pt-6 mt-4">
<x-mary-button wire:click="checkStatus" spinner="checkStatus" icon="lucide.refresh-cw"
disabled="!$credentialsLoaded" class="btn-outline">
Check Status
</x-mary-button>
<div class="flex items-center gap-2 ml-auto">
@if($connectionStatus === ConnectionStatusEnum::Connected)
<x-mary-button wire:click="disconnect" spinner="disconnect" icon="lucide.unlink"
variant="danger" class="btn-error text-white font-medium shadow-sm">
Disconnect Federation
</x-mary-button>
@else
<x-mary-button wire:click="connect" spinner="connect" icon="lucide.link-2"
disabled="!$credentialsLoaded || !$certificateLoaded"
class="btn-primary text-white font-medium shadow-sm">
Connect & Federate
</x-mary-button>
@endif
</div>
</div>
</div>
</x-shared.card>
</div>
<!-- Right Side: Connection Status Inspector details -->
<div class="space-y-6">
<x-shared.card title="Active Federation State">
<div class="p-6 space-y-4">
@if($activeFederationConfig)
<div class="space-y-4">
<div class="flex justify-between border-b border-gray-100 pb-2 text-xs">
<span class="text-gray-400 font-medium">Federation ID</span>
<span
class="text-gray-700 font-mono select-all">{{ Str::limit($activeFederationConfig['id'], 18) }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-2 text-xs">
<span class="text-gray-400 font-medium">Preferred Protocol</span>
<span
class="text-gray-700 font-semibold">{{ $activeFederationConfig['preferredAuthenticationProtocol'] ?? 'Unknown' }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-2 text-xs">
<span class="text-gray-400 font-medium">Entra MFA Rule</span>
<span
class="text-gray-700 font-semibold">{{ $activeFederationConfig['federatedIdpMfaBehavior'] ?? 'Unknown' }}</span>
</div>
<div class="space-y-1">
<span class="text-gray-400 font-medium text-xs block">Active Signing Certificate Thumbprint</span>
<div
class="bg-gray-50 font-mono text-[10px] p-2.5 rounded border border-gray-100 break-all select-all text-gray-600">
{{ hash('sha1', base64_decode($activeFederationConfig['signingCertificate'])) }}
</div>
</div>
</div>
@else
<div class="text-center py-12">
<div
class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mx-auto text-gray-400 mb-3">
<x-mary-icon name="lucide.cloud-off" class="w-6 h-6"/>
</div>
<h4 class="font-medium text-gray-700">No Active Federation</h4>
<p class="text-xs text-gray-400 mt-1 max-w-45 mx-auto leading-relaxed">Connect this IDP
to Microsoft Entra to sync active configurations.</p>
</div>
@endif
</div>
</x-shared.card>
</div>
</div>
<!-- Debugging section: Full transaction logs -->
<x-shared.card title="Microsoft Graph Transactions (Debug Logs)">
<x-slot:actions>
@if(count($debugResponses) > 0)
<x-mary-button wire:click="clearDebug" icon="lucide.trash-2"
class="btn-xs btn-outline btn-error text-red-500">
Clear Logs
</x-mary-button>
@endif
</x-slot:actions>
<div class="p-6">
@if(count($debugResponses) > 0)
<div class="space-y-4 max-h-150 overflow-y-auto pr-2 custom-scrollbar">
@foreach(array_reverse($debugResponses) as $index => $log)
<div x-data="{ open: false }"
class="rounded-xl border border-slate-800 bg-slate-950/90 shadow-lg overflow-hidden font-mono">
<!-- Log Header -->
<button @click="open = !open"
class="w-full flex flex-wrap items-center justify-between p-4 gap-4 text-left border-b border-slate-800 hover:bg-slate-900/50 transition-colors">
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">{{ $log['timestamp'] }}</span>
<span @class([
'px-2 py-0.5 rounded text-[10px] font-bold uppercase',
'bg-emerald-950 text-emerald-400 border border-emerald-800' => $log['method'] === 'GET',
'bg-sky-950 text-sky-400 border border-sky-800' => $log['method'] === 'POST',
'bg-rose-950 text-rose-400 border border-rose-800' => $log['method'] === 'DELETE',
])>{{ $log['method'] }}</span>
<span class="font-semibold text-xs text-slate-200">{{ $log['name'] }}</span>
</div>
<div class="flex items-center gap-3">
<span
class="text-slate-400 text-xs truncate max-w-50 sm:max-w-xs md:max-w-md">{{ $log['url'] }}</span>
@if(is_numeric($log['status']))
<span @class([
'px-2.5 py-0.5 rounded-full text-xs font-bold',
'bg-emerald-950 text-emerald-400 border border-emerald-850' => $log['status'] >= 200 && $log['status'] < 300,
'bg-rose-950 text-rose-400 border border-rose-850' => $log['status'] >= 400,
])>{{ $log['status'] }}</span>
@else
<span
class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-950 text-rose-400 border border-rose-850">{{ $log['status'] }}</span>
@endif
<span class="text-slate-500 text-xs" :class="open ? 'rotate-180' : ''">
<x-mary-icon name="lucide.chevron-down"
class="w-4 h-4 transition-transform"/>
</span>
</div>
</button>
<!-- Log Body details -->
<div x-cloak x-show="open" class="p-4 space-y-4 border-t border-slate-900 text-xs">
<!-- Request Details -->
@if(!empty($log['request_headers']) || !empty($log['request_body']))
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@if(!empty($log['request_headers']))
<div class="space-y-1">
<span
class="text-slate-500 font-sans font-medium uppercase text-[10px]">Request Headers</span>
<pre
class="bg-slate-900 text-emerald-500/80 p-3 rounded-lg overflow-x-auto text-[11px] leading-relaxed">{{ json_encode($log['request_headers'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
@endif
@if(!empty($log['request_body']))
<div class="space-y-1">
<span
class="text-slate-500 font-sans font-medium uppercase text-[10px]">Request Payload</span>
<pre
class="bg-slate-900 text-emerald-500/80 p-3 rounded-lg overflow-x-auto text-[11px] leading-relaxed">{{ json_encode($log['request_body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
@endif
</div>
@endif
<!-- Response Details -->
<div class="space-y-1">
<span class="text-slate-500 font-sans font-medium uppercase text-[10px]">Response Body Payload</span>
<pre
class="bg-slate-900 text-cyan-400/90 p-4 rounded-lg overflow-x-auto text-[11px] leading-relaxed">{{ json_encode($log['response_body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-12 text-gray-400">
<div
class="w-12 h-12 rounded-full bg-gray-50 flex items-center justify-center mx-auto text-gray-400 mb-3 border border-gray-100">
<x-mary-icon name="lucide.terminal" class="w-5 h-5"/>
</div>
<h4 class="font-medium text-gray-600">Terminal Output Empty</h4>
<p class="text-xs text-gray-400 mt-1 max-w-60 mx-auto leading-relaxed">Federation
transactions will appear here as HTTP raw records when you click Check, Connect, or
Disconnect.</p>
</div>
@endif
</div>
</x-shared.card>
</div>
</div>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.2);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
</style>

View File

@ -57,6 +57,10 @@
}); });
Route::prefix('apps')->name('apps.')->group(function (): void { Route::prefix('apps')->name('apps.')->group(function (): void {
Route::livewire('microsoft-federation', 'pages::connecton-providers.microsoft-federation')
->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access))
->name('microsoft-federation');
Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void { Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void {
Route::livewire('create', 'pages::connected-apps.create')->name('create'); Route::livewire('create', 'pages::connected-apps.create')->name('create');
Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit'); Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit');

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use App\Enums\ConnectionStatusEnum;
use App\Models\User;
use Illuminate\Support\Facades\{Config, File, Http};
use Livewire\Livewire;
beforeEach(function (): void {
$this->seed(Database\Seeders\DatabaseSeeder::class);
$this->admin = User::where('email', 'admin@example.com')->firstOrFail();
// Ensure mock SAML certificate directory and file exists for tests
$directory = storage_path('app/saml');
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
if (! File::exists("{$directory}/idp.crt")) {
File::put("{$directory}/idp.crt", "-----BEGIN CERTIFICATE-----\nMIIE3jCCAsagAwIBAgIQQcyDaZz3MI\n-----END CERTIFICATE-----");
}
});
test('guest is redirected to login when visiting microsoft federation page', function (): void {
$response = $this->get(route('apps.microsoft-federation'));
$response->assertRedirect(route('login'));
});
test('administrator can render microsoft federation page', function (): void {
$response = $this->actingAs($this->admin)->get(route('apps.microsoft-federation'));
$response->assertStatus(200);
});
test('renders warning banner if credentials are not configured in environment', function (): void {
Config::set('services.microsoft_graph', [
'tenant_id' => '',
'client_id' => '',
'client_secret' => '',
'domain_name' => '',
]);
Livewire::actingAs($this->admin)
->test('pages::connecton-providers.microsoft-federation')
->assertSet('credentialsLoaded', false)
->assertSee('Environment Configuration Required');
});
test('loads credentials and check status successfully if configured', function (): void {
Config::set('services.microsoft_graph', [
'tenant_id' => 'mock-tenant-id',
'client_id' => 'mock-client-id',
'client_secret' => 'mock-client-secret',
'domain_name' => 'mockdomain.com',
'display_name' => 'Company SSO',
'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp',
]);
// Fake Microsoft Graph API responses
Http::fake([
'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([
'access_token' => 'mock-access-token',
'token_type' => 'Bearer',
'expires_in' => 3600,
], 200),
'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration' => Http::response([
'value' => [
[
'id' => 'mock-federation-id',
'displayName' => 'Company SSO',
'issuerUri' => 'http://localhost/saml/metadata',
'preferredAuthenticationProtocol' => 'saml',
'federatedIdpMfaBehavior' => 'acceptIfMfaDoneByFederatedIdp',
'signingCertificate' => 'MIIE3jCCAsagAwIBAgIQQcyDaZz3MI',
],
],
], 200),
]);
Livewire::actingAs($this->admin)
->test('pages::connecton-providers.microsoft-federation')
->assertSet('credentialsLoaded', true)
->assertSet('connectionStatus', ConnectionStatusEnum::Connected)
->assertSet('federationId', 'mock-federation-id')
->assertSee('Active Federation State');
});
test('can connect/federate domain successfully', function (): void {
Config::set('services.microsoft_graph', [
'tenant_id' => 'mock-tenant-id',
'client_id' => 'mock-client-id',
'client_secret' => 'mock-client-secret',
'domain_name' => 'mockdomain.com',
'display_name' => 'Company SSO',
'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp',
]);
Http::fake([
'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([
'access_token' => 'mock-access-token',
], 200),
'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration' => function ($request) {
if ('GET' === $request->method()) {
return Http::response(['value' => []], 200);
}
if ('POST' === $request->method()) {
return Http::response([
'id' => 'new-federation-id',
'displayName' => 'Company SSO',
'issuerUri' => 'http://localhost/saml/metadata',
'preferredAuthenticationProtocol' => 'saml',
'federatedIdpMfaBehavior' => 'acceptIfMfaDoneByFederatedIdp',
'signingCertificate' => 'MIIE3jCCAsagAwIBAgIQQcyDaZz3MI',
], 200);
}
return Http::response(null, 404);
},
]);
Livewire::actingAs($this->admin)
->test('pages::connecton-providers.microsoft-federation')
->set('connectionStatus', ConnectionStatusEnum::Disconnected)
->call('connect')
->assertSet('connectionStatus', ConnectionStatusEnum::Connected)
->assertSet('federationId', 'new-federation-id');
});
test('can disconnect federation successfully', function (): void {
Config::set('services.microsoft_graph', [
'tenant_id' => 'mock-tenant-id',
'client_id' => 'mock-client-id',
'client_secret' => 'mock-client-secret',
'domain_name' => 'mockdomain.com',
'display_name' => 'Company SSO',
'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp',
]);
Http::fake([
'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([
'access_token' => 'mock-access-token',
], 200),
'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration/mock-federation-id' => Http::response(null, 204),
]);
Livewire::actingAs($this->admin)
->test('pages::connecton-providers.microsoft-federation')
->set('connectionStatus', ConnectionStatusEnum::Connected)
->set('federationId', 'mock-federation-id')
->call('disconnect')
->assertSet('connectionStatus', ConnectionStatusEnum::Disconnected)
->assertSet('federationId', null);
});