272 lines
10 KiB
PHP
272 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Integrations\Microsoft;
|
|
|
|
use App\Domain\Identity\Exceptions\MicrosoftAuthenticationException;
|
|
use Firebase\JWT\JWK;
|
|
use Firebase\JWT\JWT;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
final class MicrosoftOidcClient
|
|
{
|
|
/** @return array{url:string,flow:array{state:string,nonce:string,code_verifier:string,created_at:int}} */
|
|
public function authorizationRequest(): array
|
|
{
|
|
$this->ensureConfigured();
|
|
$metadata = $this->metadata();
|
|
$state = Str::random(64);
|
|
$nonce = Str::random(64);
|
|
$codeVerifier = Str::random(96);
|
|
$challenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
|
|
|
return [
|
|
'url' => $metadata['authorization_endpoint'].'?'.http_build_query([
|
|
'client_id' => $this->clientId(),
|
|
'response_type' => 'code',
|
|
'redirect_uri' => $this->redirectUri(),
|
|
'response_mode' => 'query',
|
|
'scope' => 'openid profile email',
|
|
'state' => $state,
|
|
'nonce' => $nonce,
|
|
'code_challenge' => $challenge,
|
|
'code_challenge_method' => 'S256',
|
|
], '', '&', PHP_QUERY_RFC3986),
|
|
'flow' => [
|
|
'state' => $state,
|
|
'nonce' => $nonce,
|
|
'code_verifier' => $codeVerifier,
|
|
'created_at' => time(),
|
|
],
|
|
];
|
|
}
|
|
|
|
/** @return array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} */
|
|
public function authenticate(string $code, string $codeVerifier, string $nonce): array
|
|
{
|
|
$this->ensureConfigured();
|
|
$metadata = $this->metadata();
|
|
|
|
try {
|
|
$response = Http::asForm()
|
|
->acceptJson()
|
|
->timeout($this->timeout())
|
|
->post($metadata['token_endpoint'], [
|
|
'client_id' => $this->clientId(),
|
|
'client_secret' => $this->clientSecret(),
|
|
'grant_type' => 'authorization_code',
|
|
'code' => $code,
|
|
'redirect_uri' => $this->redirectUri(),
|
|
'code_verifier' => $codeVerifier,
|
|
'scope' => 'openid profile email',
|
|
]);
|
|
} catch (ConnectionException) {
|
|
throw new MicrosoftAuthenticationException('O serviço Microsoft Entra não respondeu a tempo.');
|
|
}
|
|
|
|
$idToken = $response->successful() ? $response->json('id_token') : null;
|
|
if (! is_string($idToken) || $idToken === '') {
|
|
throw new MicrosoftAuthenticationException('O Microsoft Entra não devolveu uma identidade válida.');
|
|
}
|
|
|
|
return $this->validateIdToken($idToken, $nonce, $metadata);
|
|
}
|
|
|
|
public function configured(): bool
|
|
{
|
|
return (bool) config('services.microsoft.enabled')
|
|
&& preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $this->tenantId()) === 1
|
|
&& $this->clientId() !== ''
|
|
&& $this->clientSecret() !== ''
|
|
&& filter_var($this->redirectUri(), FILTER_VALIDATE_URL) !== false
|
|
&& (! app()->isProduction() || parse_url($this->redirectUri(), PHP_URL_SCHEME) === 'https');
|
|
}
|
|
|
|
/** @param array{issuer:string,jwks_uri:string,authorization_endpoint:string,token_endpoint:string} $metadata
|
|
* @return array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string}
|
|
*/
|
|
private function validateIdToken(string $idToken, string $nonce, array $metadata): array
|
|
{
|
|
try {
|
|
$claims = $this->decode($idToken, $this->jwks($metadata['jwks_uri']));
|
|
} catch (Throwable) {
|
|
Cache::forget($this->jwksCacheKey());
|
|
|
|
try {
|
|
$claims = $this->decode($idToken, $this->jwks($metadata['jwks_uri']));
|
|
} catch (Throwable) {
|
|
throw new MicrosoftAuthenticationException('Não foi possível validar a assinatura da identidade Microsoft.');
|
|
}
|
|
}
|
|
|
|
$expected = [
|
|
'iss' => [$metadata['issuer'], false],
|
|
'aud' => [$this->clientId(), false],
|
|
'tid' => [strtolower($this->tenantId()), true],
|
|
'nonce' => [$nonce, false],
|
|
];
|
|
foreach ($expected as $claim => [$value, $caseInsensitive]) {
|
|
$actual = $claims[$claim] ?? null;
|
|
if (! is_string($actual)
|
|
|| ! hash_equals(
|
|
$caseInsensitive ? strtolower($value) : $value,
|
|
$caseInsensitive ? strtolower($actual) : $actual,
|
|
)) {
|
|
throw new MicrosoftAuthenticationException('A identidade Microsoft não corresponde à configuração institucional.');
|
|
}
|
|
}
|
|
|
|
if (($claims['ver'] ?? null) !== '2.0'
|
|
|| ! is_numeric($claims['iat'] ?? null)
|
|
|| ! is_numeric($claims['exp'] ?? null)
|
|
|| ! is_string($claims['sub'] ?? null)
|
|
|| $claims['sub'] === ''
|
|
|| strlen($claims['sub']) > 255) {
|
|
throw new MicrosoftAuthenticationException('A identidade Microsoft não contém os identificadores obrigatórios.');
|
|
}
|
|
|
|
$email = $claims['email'] ?? $claims['preferred_username'] ?? null;
|
|
if (! is_string($email) || strlen($email) > 255 || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
|
throw new MicrosoftAuthenticationException('A conta Microsoft não disponibilizou um endereço de email válido.');
|
|
}
|
|
|
|
return [
|
|
'sub' => $claims['sub'],
|
|
'oid' => is_string($claims['oid'] ?? null) ? $claims['oid'] : null,
|
|
'tid' => strtolower($claims['tid']),
|
|
'email' => Str::lower($email),
|
|
'name' => is_string($claims['name'] ?? null) ? $claims['name'] : $email,
|
|
'preferred_username' => is_string($claims['preferred_username'] ?? null)
|
|
? $claims['preferred_username']
|
|
: null,
|
|
];
|
|
}
|
|
|
|
/** @param array<string, mixed> $jwks
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function decode(string $idToken, array $jwks): array
|
|
{
|
|
$keys = JWK::parseKeySet($jwks, 'RS256');
|
|
$previousLeeway = JWT::$leeway;
|
|
JWT::$leeway = 60;
|
|
|
|
try {
|
|
return (array) JWT::decode($idToken, $keys);
|
|
} finally {
|
|
JWT::$leeway = $previousLeeway;
|
|
}
|
|
}
|
|
|
|
/** @return array{issuer:string,jwks_uri:string,authorization_endpoint:string,token_endpoint:string} */
|
|
private function metadata(): array
|
|
{
|
|
/** @var array<string, mixed> $metadata */
|
|
$metadata = Cache::remember(
|
|
$this->metadataCacheKey(),
|
|
max(60, (int) config('services.microsoft.metadata_cache_seconds', 3600)),
|
|
fn (): array => $this->getJson($this->authority().'/v2.0/.well-known/openid-configuration'),
|
|
);
|
|
|
|
$expectedIssuer = $this->authority().'/v2.0';
|
|
$result = [];
|
|
foreach (['issuer', 'jwks_uri', 'authorization_endpoint', 'token_endpoint'] as $key) {
|
|
if (! is_string($metadata[$key] ?? null) || ! $this->trustedMicrosoftUrl($metadata[$key])) {
|
|
throw new MicrosoftAuthenticationException('A configuração OIDC do Microsoft Entra é inválida.');
|
|
}
|
|
$result[$key] = $metadata[$key];
|
|
}
|
|
|
|
if (! hash_equals($expectedIssuer, $result['issuer'])) {
|
|
throw new MicrosoftAuthenticationException('O emissor OIDC não corresponde ao tenant configurado.');
|
|
}
|
|
|
|
/** @var array{issuer:string,jwks_uri:string,authorization_endpoint:string,token_endpoint:string} $result */
|
|
return $result;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function jwks(string $url): array
|
|
{
|
|
/** @var array<string, mixed> */
|
|
return Cache::remember(
|
|
$this->jwksCacheKey(),
|
|
max(60, (int) config('services.microsoft.metadata_cache_seconds', 3600)),
|
|
fn (): array => $this->getJson($url),
|
|
);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function getJson(string $url): array
|
|
{
|
|
try {
|
|
$response = Http::acceptJson()->timeout($this->timeout())->get($url);
|
|
} catch (ConnectionException) {
|
|
throw new MicrosoftAuthenticationException('O serviço de identidade Microsoft está indisponível.');
|
|
}
|
|
|
|
$json = $response->successful() ? $response->json() : null;
|
|
if (! is_array($json)) {
|
|
throw new MicrosoftAuthenticationException('O serviço de identidade Microsoft devolveu uma resposta inválida.');
|
|
}
|
|
|
|
return $json;
|
|
}
|
|
|
|
private function ensureConfigured(): void
|
|
{
|
|
if (! $this->configured()) {
|
|
throw new MicrosoftAuthenticationException('A autenticação Microsoft ainda não está configurada.');
|
|
}
|
|
}
|
|
|
|
private function trustedMicrosoftUrl(string $url): bool
|
|
{
|
|
return parse_url($url, PHP_URL_SCHEME) === 'https'
|
|
&& strtolower((string) parse_url($url, PHP_URL_HOST)) === 'login.microsoftonline.com';
|
|
}
|
|
|
|
private function authority(): string
|
|
{
|
|
return 'https://login.microsoftonline.com/'.strtolower($this->tenantId());
|
|
}
|
|
|
|
private function metadataCacheKey(): string
|
|
{
|
|
return 'microsoft.oidc.metadata.'.hash('sha256', strtolower($this->tenantId()));
|
|
}
|
|
|
|
private function jwksCacheKey(): string
|
|
{
|
|
return 'microsoft.oidc.jwks.'.hash('sha256', strtolower($this->tenantId()));
|
|
}
|
|
|
|
private function clientId(): string
|
|
{
|
|
return trim((string) config('services.microsoft.client_id'));
|
|
}
|
|
|
|
private function clientSecret(): string
|
|
{
|
|
return (string) config('services.microsoft.client_secret');
|
|
}
|
|
|
|
private function tenantId(): string
|
|
{
|
|
return trim((string) config('services.microsoft.tenant_id'));
|
|
}
|
|
|
|
private function redirectUri(): string
|
|
{
|
|
return trim((string) config('services.microsoft.redirect'));
|
|
}
|
|
|
|
private function timeout(): int
|
|
{
|
|
return max(1, (int) config('services.microsoft.timeout', 10));
|
|
}
|
|
}
|