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 $jwks * @return array */ 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 $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 */ private function jwks(string $url): array { /** @var array */ return Cache::remember( $this->jwksCacheKey(), max(60, (int) config('services.microsoft.metadata_cache_seconds', 3600)), fn (): array => $this->getJson($url), ); } /** @return array */ 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)); } }