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:
parent
f61acb8907
commit
df4a84b970
@ -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"
|
||||||
|
|
||||||
|
|||||||
23
app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php
Normal file
23
app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
app/Enums/FederatedIdpMfaBehaviorEnum.php
Normal file
19
app/Enums/FederatedIdpMfaBehaviorEnum.php
Normal 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';
|
||||||
|
}
|
||||||
364
app/Services/MicrosoftGraphService.php
Normal file
364
app/Services/MicrosoftGraphService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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)
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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');
|
||||||
|
|||||||
154
tests/Feature/MicrosoftGraphFederationTest.php
Normal file
154
tests/Feature/MicrosoftGraphFederationTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user