| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- <?php
- declare(strict_types=1);
- namespace App\Service\HelloAsso;
- use App\ApiResources\HelloAsso\AuthUrl;
- use App\Entity\Organization\HelloAsso;
- use App\Entity\Organization\Organization;
- use App\Service\Rest\ApiRequestService;
- use App\Service\Security\OAuthPkceGenerator;
- use App\Service\Utils\UrlBuilder;
- use Doctrine\ORM\EntityManagerInterface;
- use Symfony\Contracts\HttpClient\HttpClientInterface;
- use Symfony\Contracts\HttpClient\ResponseInterface;
- use Symfony\Component\HttpKernel\Exception\HttpException;
- /**
- * Service de connexion à HelloAsso.
- *
- * @see doc/helloasso.md
- * @see https://dev.helloasso.com/docs/mire-authorisation
- */
- class ConnectionService extends ApiRequestService
- {
- public function __construct(
- HttpClientInterface $client,
- private readonly string $baseUrl,
- private readonly string $publicAppBaseUrl,
- private readonly string $helloAssoApiBaseUrl,
- private readonly string $helloAssoAuthBaseUrl,
- private readonly string $helloAssoClientId,
- private readonly string $helloAssoClientSecret,
- private readonly EntityManagerInterface $entityManager,
- ) {
- parent::__construct($client);
- }
- /**
- * Se connecte à Helloasso en tant qu'organisation Opentalent, et met à jour son domaine.
- * Le domaine doit correspondre à celui utilisé pour les callbacks, sans quoi ceux ci seront bloqués
- * pour des raisons de sécurité.
- *
- * En principe, cette opération n'est réalisée qu'une seule fois.
- *
- * @see doc/helloasso.md#2-enregistrement-de-votre-domaine-de-redirection
- * @see https://dev.helloasso.com/reference/put_partners-me-api-clients
- *
- * @return void
- */
- public function setupOpentalentDomain(): void {
- $accessToken = $this->fetchAccessToken(null);
- $this->updateDomain($accessToken, $this->publicAppBaseUrl);
- }
- /**
- * Créé l'URL du formulaire d'authentification HelloAsso
- *
- * @see doc/helloasso.md#se-connecter-avec-helloasso
- */
- public function getAuthUrl(): AuthUrl
- {
- $callbackUrl = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso']);
- $challenge = OAuthPkceGenerator::generatePkce();
- $params = [
- 'client_id' => $this->helloAssoClientId,
- 'redirect_uri' => $callbackUrl,
- 'code_challenge' => $challenge['challenge'],
- 'code_challenge_method' => 'S256'
- ];
- $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params);
- $challengeVerifier = $challenge['verifier'];
- $authUrlResource = new AuthUrl();
- $authUrlResource->setAuthUrl($authUrl);
- $authUrlResource->setChallengeVerifier($challengeVerifier);
- return $authUrlResource;
- }
- /**
- * Establishes a connection for a specific organization with HelloAsso.
- *
- * @see doc/helloasso.md#r%C3%A9cup%C3%A9rer-et-stocker-les-jetons-dacc%C3%A8s
- *
- * @param int $organizationId The ID of the organization to connect.
- * @param string $authorizationCode Le code d'autorisation Helloasso fourni après l'authentification de l'utilisateur.'
- *
- * @return HelloAsso The HelloAsso entity for the organization.
- *
- * @throws \RuntimeException If the organization is not found or if any connection step fails.
- */
- public function connect(int $organizationId, string $authorizationCode): HelloAsso
- {
- $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
- if (!$organization) {
- throw new \RuntimeException('Organization not found');
- }
- $tokens = $this->fetchAccessToken($authorizationCode);
- if ($tokens['token_type'] !== 'bearer') {
- throw new \RuntimeException('Invalid token type received');
- }
- $helloAssoEntity = $organization->getHelloAsso();
- if (!$helloAssoEntity) {
- $helloAssoEntity = new HelloAsso();
- $helloAssoEntity->setOrganization($organization);
- }
- $helloAssoEntity->setToken($tokens['access_token']);
- $helloAssoEntity->setRefreshToken($tokens['refresh_token']);
- $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null);
- $this->entityManager->persist($helloAssoEntity);
- $this->entityManager->flush();
- return $helloAssoEntity;
- }
- /**
- * Récupère les jetons d'accès auprès de l'API HelloAsso.
- *
- * @param string|null $authorizationCode Le code d'autorisation HelloAsso. Si ce code n'est pas fourni, les jetons
- * retournés seront pour le compte principal Opentalent et non pour une
- * organisation (par exemple pour la mise à jour du domaine).
- *
- * @return array<string, string> An array containing access token details: access_token, refresh_token, token_type, and expires_in.
- *
- * @throws \InvalidArgumentException If an authorization code is required but not provided for organization tokens.
- * @throws \JsonException If the authentication response cannot be parsed.
- * @throws HttpException If there is an error in parsing the authentication response or the request fails.
- */
- protected function fetchAccessToken(string | null $authorizationCode): array
- {
- $grantType = $authorizationCode !== null ? 'authorization_code' : 'client_credentials';
- $body = [
- 'grant_type' => $grantType,
- 'client_id' => $this->helloAssoClientId,
- 'client_secret' => $this->helloAssoClientSecret,
- ];
- if ($authorizationCode !== null) {
- $body['code'] = $authorizationCode;
- $body['redirect_uri'] = UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
- }
- $options = [
- 'headers' => [
- 'Content-Type' => 'application/x-www-form-urlencoded',
- ],
- 'body' => http_build_query($body),
- ];
- $response = $this->post(
- UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
- null,
- [],
- $options
- );
- try {
- $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
- return [
- 'access_token' => $data['access_token'] ?? null,
- 'refresh_token' => $data['refresh_token'] ?? null,
- 'token_type' => $data['token_type'] ?? 'Bearer',
- 'expires_in' => $data['expires_in'] ?? null,
- ];
- } catch (\JsonException $e) {
- throw new HttpException(500, 'Failed to parse authentication response: ' . $e->getMessage(), $e);
- }
- }
- /**
- * Updates the domain configuration
- *
- * @throws HttpException
- */
- protected function updateDomain(string $accessToken, string $domain): void
- {
- $response = $this->put(
- UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
- ['domain' => $domain],
- [],
- ['headers' => [
- 'Authorization' => 'Bearer ' . $accessToken,
- 'Content-Type' => 'application/json']
- ],
- );
- if ($response->getStatusCode() !== 200) {
- throw new HttpException(500, 'Failed to update domain: ' . $response->getContent());
- }
- }
- /**
- * Retourne l'URL de redirection pour le callback HelloAsso.
- * Valide que l'URL de destination correspond au format autorisé et transmet
- * les paramètres de query (excepté 'state').
- *
- * @param array<string, string> $queryParameters Paramètres de la requête originale
- * @return string L'URL vers laquelle rediriger l'utilisateur
- * @throws HttpException Si le paramètre 'state' est manquant ou l'URL invalide
- */
- public function forwardCallbackTo(array $queryParameters): string
- {
- if (!isset($queryParameters['state']) || empty($queryParameters['state'])) {
- throw new HttpException(400, 'Missing required state parameter');
- }
- $redirectUrl = $queryParameters['state'];
- // Validation du format de l'URL (doit être https://***.opentalent.fr?***)
- if (!preg_match('/^https:\/\/[^\/]+\.opentalent\.fr(\/[\w-]+)*(\?.*)?$/', $redirectUrl)) {
- throw new HttpException(400, 'Invalid redirect URL format. Must be https://***.opentalent.fr/...');
- }
- // Supprimer le paramètre 'state' des paramètres à transmettre
- $forwardParams = $queryParameters;
- unset($forwardParams['state']);
- return UrlBuilder::concatParameters($redirectUrl, $forwardParams);
- }
- }
|