fetchAccessToken(null); $this->updateDomain($accessToken, 'https://*.opentalent.fr'); } /** * Créé l'URL du formulaire d'authentification HelloAsso. * * @see doc/helloasso.md#se-connecter-avec-helloasso * * @param int $organizationId the ID of the organization to connect */ public function getAuthUrl(int $organizationId): AuthUrl { $organization = $this->organizationRepository->find($organizationId); if (!$organization) { throw new \RuntimeException('Organization not found'); } $challenge = OAuthPkceGenerator::generatePkce(); $params = [ 'client_id' => $this->helloAssoClientId, 'redirect_uri' => $this->getCallbackUrl(), 'code_challenge' => $challenge['challenge'], 'code_challenge_method' => 'S256', ]; $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params); $challengeVerifier = $challenge['verifier']; $helloAssoEntity = $organization->getHelloAsso(); if (!$helloAssoEntity) { $helloAssoEntity = new HelloAsso(); $helloAssoEntity->setOrganization($organization); } $helloAssoEntity->setChallengeVerifier($challengeVerifier); $this->entityManager->persist($helloAssoEntity); $this->entityManager->flush(); $authUrlResource = new AuthUrl(); $authUrlResource->setAuthUrl($authUrl); 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->organizationRepository->find($organizationId); if (!$organization) { throw new \RuntimeException('Organization not found'); } $helloAssoEntity = $organization->getHelloAsso(); if (!$helloAssoEntity) { throw new \RuntimeException('HelloAsso entity not found'); } $tokens = $this->fetchAccessToken( $authorizationCode, $helloAssoEntity->getChallengeVerifier() ); if ($tokens['token_type'] !== 'bearer') { throw new \RuntimeException('Invalid token type received'); } $helloAssoEntity->setToken($tokens['access_token']); $helloAssoEntity->setTokenCreatedAt(DatesUtils::new()); $helloAssoEntity->setRefreshToken($tokens['refresh_token']); $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new()); $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null); $helloAssoEntity->setChallengeVerifier(null); $this->entityManager->persist($helloAssoEntity); $this->entityManager->flush(); return $helloAssoEntity; } /** * Génère le profil HelloAsso pour une organisation. * * @param int $organizationId * @return HelloAssoProfile */ public function makeHelloAssoProfile(int $organizationId): HelloAssoProfile { $organization = $this->organizationRepository->find($organizationId); if (!$organization) { throw new \RuntimeException('Organization not found'); } $profile = new HelloAssoProfile(); $helloAssoEntity = $organization->getHelloAsso(); if (!$helloAssoEntity) { return $profile; } $profile->setExisting(true); $profile->setToken($helloAssoEntity->getToken()); $profile->setOrganizationSlug($helloAssoEntity->getOrganizationSlug()); return $profile; } public function unlinkHelloAssoAccount(int $organizationId): void { $organization = $this->organizationRepository->find($organizationId); if (!$organization) { throw new \RuntimeException('Organization not found'); } $helloAssoEntity = $organization->getHelloAsso(); if (!$helloAssoEntity) { return; } $helloAssoEntity->setToken(null); $helloAssoEntity->setTokenCreatedAt(null); $helloAssoEntity->setRefreshToken(null); $helloAssoEntity->setRefreshTokenCreatedAt(null); $helloAssoEntity->setOrganizationSlug(null); $this->entityManager->persist($helloAssoEntity); $this->entityManager->flush(); } public function getResource(HelloAsso $helloAssoEntity, array $routeParts): array { if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) { throw new \RuntimeException('HelloAsso entity incomplete'); } $helloAssoEntity = $this->refreshTokenIfNeeded($helloAssoEntity); $url = UrlBuilder::concat( $this->helloAssoApiBaseUrl, array_merge(['/v5'], $routeParts), ); $response = $this->get( $url, [], ['headers' => [ 'accept' => 'application/json', 'authorization' => 'Bearer '.$helloAssoEntity->getToken(), ] ] ); if ($response->getStatusCode() !== 200) { throw new HttpException( 500, 'Failed to fetch resource: ['.$response->getStatusCode().'] '.$response->getContent(false) ); } return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); } public function getHelloAssoEventForms(int $organizationId): array { $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId); $data = $this->getResource( $helloAssoEntity, ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms'] ); $forms = []; foreach ($data['data'] as $formData) { $forms[] = $this->makeHelloAssoEventForm($formData); } return $forms; } public function getHelloAssoEventForm(int $organizationId, string $formSlug): EventForm { $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId); $formType = 'Event'; $data = $this->getResource( $helloAssoEntity, ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms', $formType, $formSlug, 'public'] ); return $this->makeHelloAssoEventForm($data); } public function getHelloAssoEventFormByEventId(int $eventId): EventForm { $event = $this->eventRepository->find($eventId); if (!$event) { throw new \RuntimeException('Event not found'); } $helloAssoFormSlug = $event->getHelloAssoSlug(); if (!$helloAssoFormSlug) { throw new \RuntimeException('HelloAsso form slug not found'); } return $this->getHelloAssoEventForm($organizationId, $helloAssoFormSlug); } protected function getHelloAssoEntityFor(int $organizationId, bool $shallHaveToken = true): HelloAsso { $organization = $this->organizationRepository->find($organizationId); if (!$organization) { throw new \RuntimeException('Organization not found'); } $helloAssoEntity = $organization->getHelloAsso(); if (!$helloAssoEntity) { throw new \RuntimeException('HelloAsso entity not found'); } if ($shallHaveToken && (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken())) { throw new \RuntimeException('HelloAsso entity incomplete'); } return $helloAssoEntity; } /** * Construit un objet EventForm à partir des données retournées par l'api HelloAsso. * @param array $formData * @return EventForm */ protected function makeHelloAssoEventForm(array $formData): EventForm { $form = new EventForm(); $form->setSlug($formData['formSlug']); $form->setTitle($formData['title']); $form->setWidgetUrl($formData['widgetFullUrl']); return $form; } /** * Génère l'URL de rappel pour les callbacks suite à l'authentification HelloAsso * * @return string */ protected function getCallbackUrl(): string { return UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']); } /** * 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 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 $authorizationCode, ?string $challengeVerifier): 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'] = $this->getCallbackUrl(); } if ($challengeVerifier !== null) { $body['code_verifier'] = $challengeVerifier; } $response = $this->client->request('POST', UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']), [ 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => $body, ] ); if ($response->getStatusCode() !== 200) { throw new HttpException(500, 'Failed to fetch access token: '.$response->getContent(false)); } 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, 'organization_slug' => $data['organization_slug'] ?? 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()); } } public function refreshTokenIfNeeded(HelloAsso $helloAssoEntity): HelloAsso { if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) { throw new \RuntimeException('HelloAsso entity incomplete'); } // Les tokens ont une durée de validité de 30min, on les rafraichit passé 25min. $needsRefreshing = $helloAssoEntity->getRefreshTokenCreatedAt()->add(new \DateInterval('PT25M')) < DatesUtils::new(); if (!$needsRefreshing) { return $helloAssoEntity; } return $this->refreshTokens($helloAssoEntity); } public function refreshTokens(HelloAsso $helloAssoEntity): HelloAsso { if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) { throw new \RuntimeException('HelloAsso entity incomplete'); } $body = [ 'grant_type' => 'refresh_token', 'refresh_token' => $helloAssoEntity->getRefreshToken(), ]; $response = $this->client->request('POST', UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']), [ 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => $body, ] ); if ($response->getStatusCode() !== 200) { throw new HttpException(500, 'Failed to refresh access token: '.$response->getContent(false)); } $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); $helloAssoEntity->setToken($data['access_token']); $helloAssoEntity->setTokenCreatedAt(DatesUtils::new()); $helloAssoEntity->setRefreshToken($data['refresh_token']); $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new()); $this->entityManager->persist($helloAssoEntity); $this->entityManager->flush(); return $helloAssoEntity; } }