Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Guidance on runtime dynamic Resource Owner configuration #1959

Open
jeanetienne opened this issue Oct 11, 2023 · 5 comments
Open

Guidance on runtime dynamic Resource Owner configuration #1959

jeanetienne opened this issue Oct 11, 2023 · 5 comments
Labels

Comments

@jeanetienne
Copy link

Q A
Bug? no
New Feature? no
Support question? yes
Version 2.x

Hi 👋

I'm working on a Symfony 6.3 project using HWIOAuthBundle 2.0.0, and I'm looking for guidance on making resource owner configurations dynamic and updatable at runtime.

I want to allow specific users ("Admin" users) to set the client_id and client_secret for built-in resource owners (e.g., GitHub, BitBucket, GitLab, LinkedIn). The client_id and client_secret pairs are stored in a database and will be updated infrequently, making them suitable for heavy caching.

At the moment I'm toying with a Compiler Pass that updates the definition for each resource owners, as necessary. This feels a bit over-engineered, and I don't know how to tell the service container to recompile the service when the values have been updated?

What's the best or simplest way to achieve this? Is this even a supported use case, or will I have to find a way around it for the time being?

Thanks for your help, and thanks for publishing this bundle 🙏 !

Copy link

Message to comment on stale issues. If none provided, will not mark issues stale

@github-actions github-actions bot added the Stale label Feb 23, 2024
@Gerben321
Copy link

Have you found anything about this? I would like to set the configuration in the database as well. I've got a use case where I have one codebase with different domains that need different configs.

@jeanetienne
Copy link
Author

Have you found anything about this? I would like to set the configuration in the database as well. I've got a use case where I have one codebase with different domains that need different configs.

Yes, I found a way, it's not super elegant but it works:

I added an injection pass in the Kernel to pass the keys dynamically:

class OAuthResourceServersInjectionPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $this->setupContainer($container, 'hwi_oauth.resource_owner.github', 'users.resource_server.github.client_id', 'users.resource_server.github.client_secret');
    }

    private function setupContainer(ContainerBuilder $container, string $resourceServerIdentifier, string $clientIdConfigurationKey, string $clientSecretConfigurationKey)
    {
        if ($container->has($resourceServerIdentifier)) {
            $definition = $container->findDefinition($resourceServerIdentifier);
            $definition->addMethodCall('setEntityManager', [new Reference('doctrine.orm.entity_manager')]);
            $definition->addMethodCall('setClientIdConfigurationKey', [$clientIdConfigurationKey]);
            $definition->addMethodCall('setClientSecretConfigurationKey', [$clientSecretConfigurationKey]);
        }
    }
}

Then I had to recreate (mostly copypaste and tweak) the "ResourceOwner" classes to accept dynamic values:

  • abstract class DynamicOAuth2ResourceServer extends GenericOAuth2ResourceOwner
  • final class GitHubResourceServer extends DynamicOAuth2ResourceServer

Hope that helps?

@zerowebcorp
Copy link

Looking for the same. Do you have the full code that we can review?

@jeanetienne
Copy link
Author

That's what I have so far, verbatim from my project. I'll let you remove what isn't relevant to your use case and filter out my stuff (My project is called Spectram).

Side note: my understanding of OAuth is that the resource owner is the actual human (the end user), and the service they're using to connect is the resource server. I have reflected that in the naming of my classes (e.g. DynamicOAuth2ResourceServer from me extends GenericOAuth2ResourceOwner from HWIOAuthBundle).

Below is the relevant code. Hope this helps.

Kernel.php

<?php

namespace App;

use App\Service\OAuthResourceServer\OAuthResourceServersInjectionPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new OAuthResourceServersInjectionPass());
    }
}

OAuthResourceServersInjectionPass.php

<?php

namespace App\Service\OAuthResourceServer;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class OAuthResourceServersInjectionPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $this->setupContainer($container, 'hwi_oauth.resource_owner.github', 'users.resource_server.github.client_id', 'users.resource_server.github.client_secret');
        $this->setupContainer($container, 'hwi_oauth.resource_owner.google', 'users.resource_server.google.client_id', 'users.resource_server.google.client_secret');
    }

    private function setupContainer(ContainerBuilder $container, string $resourceServerIdentifier, string $clientIdConfigurationKey, string $clientSecretConfigurationKey)
    {
        if ($container->has($resourceServerIdentifier)) {
            $definition = $container->findDefinition($resourceServerIdentifier);
            $definition->addMethodCall('setEntityManager', [new Reference('doctrine.orm.entity_manager')]);
            $definition->addMethodCall('setClientIdConfigurationKey', [$clientIdConfigurationKey]);
            $definition->addMethodCall('setClientSecretConfigurationKey', [$clientSecretConfigurationKey]);
        }
    }
}

DynamicOAuth2ResourceServer.php

<?php

namespace App\Service\OAuthResourceServer;

use App\Entity\SpectramConfiguration;
use Doctrine\ORM\EntityManagerInterface;
use HWI\Bundle\OAuthBundle\OAuth\ResourceOwner\GenericOAuth2ResourceOwner;
use HWI\Bundle\OAuthBundle\Security\Helper\NonceGenerator;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Contracts\HttpClient\ResponseInterface;

abstract class DynamicOAuth2ResourceServer extends GenericOAuth2ResourceOwner
{
    public const TYPE = null; // it must be null

    private EntityManagerInterface $entityManager;
    protected string $clientIdConfigurationKey = '';
    protected string $clientSecretConfigurationKey = '';

    public function setEntityManager(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function setClientIdConfigurationKey(string $clientIdConfigurationKey)
    {
        $this->clientIdConfigurationKey = $clientIdConfigurationKey;
    }

    public function setClientSecretConfigurationKey(string $clientSecretConfigurationKey)
    {
        $this->clientSecretConfigurationKey = $clientSecretConfigurationKey;
    }

    protected function getClientId(): string {
        return $this->entityManager
            ->getRepository(SpectramConfiguration::class)
            ->findOneByName($this->clientIdConfigurationKey)
            ->getStringValue()
            ;
    }

    protected function getClientSecret(): string {
        return $this->entityManager
            ->getRepository(SpectramConfiguration::class)
            ->findOneByName($this->clientSecretConfigurationKey)
            ->getStringValue()
            ;
    }

    /**
     * {@inheritdoc}
     */
    public function getAuthorizationUrl($redirectUri, array $extraParameters = []): string
    {
        if ($this->options['csrf']) {
            $this->handleCsrfToken();
        }

        $parameters = array_merge([
            'response_type' => 'code',
            'client_id' => $this->getClientId(),
            'scope' => $this->options['scope'],
            'state' => $this->state->encode(),
            'redirect_uri' => $redirectUri,
        ], $extraParameters);

        return $this->normalizeUrl($this->options['authorization_url'], $parameters);
    }

    /**
     * {@inheritdoc}
     */
    public function revokeToken($token): bool
    {
        if (!isset($this->options['revoke_token_url'])) {
            throw new AuthenticationException('OAuth error: "Method unsupported."');
        }

        $parameters = [
            'client_id' => $this->getClientId(),
            'client_secret' => $this->getClientSecret(),
        ];

        $response = $this->httpRequest($this->normalizeUrl($this->options['revoke_token_url'], ['token' => $token]), $parameters, [], 'DELETE');

        return 200 === $response->getStatusCode();
    }

    /**
     * {@inheritdoc}
     */
    protected function doGetTokenRequest($url, array $parameters = []): ResponseInterface
    {
        $headers = [];
        if ($this->options['use_authorization_to_get_token']) {
            if ($this->getClientSecret()) {
                $headers['Authorization'] = 'Basic '.base64_encode($this->getClientId().':'.$this->getClientSecret());
            }
        } else {
            $parameters['client_id'] = $this->getClientId();
            $parameters['client_secret'] = $this->getClientSecret();
        }

        return $this->httpRequest($url, http_build_query($parameters, '', '&'), $headers);
    }

    private function handleCsrfToken(): void
    {
        if (null === $this->state->getCsrfToken()) {
            $this->state->setCsrfToken(NonceGenerator::generate());
        }

        $this->storage->save($this, $this->state->getCsrfToken(), 'csrf_state');
    }
}

GitHubResourceServer.php

<?php

namespace App\Service\OAuthResourceServer;

use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class GitHubResourceServer extends DynamicOAuth2ResourceServer
{
    public const TYPE = 'github';

    protected array $paths = [
        'identifier' => 'id',
        'nickname' => 'login',
        'realname' => 'name',
        'email' => 'email',
        'profilepicture' => 'avatar_url',
    ];

    public function getUserInformation(array $accessToken, array $extraParameters = []): UserResponseInterface
    {
        $response = parent::getUserInformation($accessToken, $extraParameters);

        $responseData = $response->getData();
        if (empty($responseData['email'])) {
            // fetch the email addresses linked to the account
            $content = $this->httpRequest(
                $this->normalizeUrl($this->options['emails_url']), null, ['Authorization' => 'Bearer '.$accessToken['access_token']]
            );

            foreach ($this->getResponseContent($content) as $email) {
                if (!empty($email['primary'])) {
                    // we only need the primary email address
                    $responseData['email'] = $email['email'];
                    break;
                }
            }

            $response->setData($responseData);
        }

        return $response;
    }

    public function revokeToken($token): bool
    {
        $response = $this->httpRequest(
            sprintf($this->options['revoke_token_url'], parent::getClientId()),
            json_encode(['access_token' => $token]),
            [
                'Authorization' => 'Basic '.base64_encode(parent::getClientId().':'.parent::getClientSecret()),
                'Content-Type' => 'application/json',
            ],
            'DELETE'
        );

        return 204 === $response->getStatusCode();
    }

    protected function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        $resolver->setDefaults([
            'authorization_url' => 'https://github.com/login/oauth/authorize',
            'access_token_url' => 'https://github.com/login/oauth/access_token',
            'revoke_token_url' => 'https://api.github.com/applications/%s/token',
            'infos_url' => 'https://api.github.com/user',
            'emails_url' => 'https://api.github.com/user/emails',

            'use_commas_in_scope' => true,
        ]);
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants