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 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 $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); } }