diff --git a/Controller/IntrospectionController.php b/Controller/IntrospectionController.php new file mode 100644 index 00000000..d262d1d4 --- /dev/null +++ b/Controller/IntrospectionController.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\OAuthServerBundle\Controller; + +use FOS\OAuthServerBundle\Form\Model\Introspect; +use FOS\OAuthServerBundle\Form\Type\IntrospectionFormType; +use FOS\OAuthServerBundle\Model\AccessTokenInterface; +use FOS\OAuthServerBundle\Model\RefreshTokenInterface; +use FOS\OAuthServerBundle\Model\TokenInterface; +use FOS\OAuthServerBundle\Model\TokenManagerInterface; +use FOS\OAuthServerBundle\Security\Authentication\Token\OAuthToken; +use Symfony\Component\Form\FormFactory; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; + +class IntrospectionController +{ + /** + * @var TokenStorageInterface + */ + private $tokenStorage; + + /** + * @var TokenManagerInterface + */ + private $accessTokenManager; + + /** + * @var TokenManagerInterface + */ + private $refreshTokenManager; + + /** + * @var FormFactory + */ + private $formFactory; + + /** + * @var array + */ + private $allowedIntrospectionClients; + + public function __construct( + TokenStorageInterface $tokenStorage, + TokenManagerInterface $accessTokenManager, + TokenManagerInterface $refreshTokenManager, + FormFactory $formFactory, + array $allowedIntrospectionClients + ) { + $this->tokenStorage = $tokenStorage; + $this->accessTokenManager = $accessTokenManager; + $this->refreshTokenManager = $refreshTokenManager; + $this->formFactory = $formFactory; + $this->allowedIntrospectionClients = $allowedIntrospectionClients; + } + + public function introspectAction(Request $request): JsonResponse + { + $this->denyAccessIfNotAuthorizedClient(); + + $token = $this->getToken($request); + + $isActive = $token && !$token->hasExpired(); + + if (!$isActive) { + return new JsonResponse([ + 'active' => false, + ]); + } + + return new JsonResponse([ + 'active' => true, + 'scope' => $token->getScope(), + 'client_id' => $token->getClientId(), + 'username' => $this->getUsername($token), + 'token_type' => $this->getTokenType($token), + 'exp' => $token->getExpiresAt(), + ]); + } + + /** + * Check that the caller has a token generated by an allowed client + */ + private function denyAccessIfNotAuthorizedClient(): void + { + $clientToken = $this->tokenStorage->getToken(); + + if (!$clientToken instanceof OAuthToken) { + throw new AccessDeniedException('The introspect endpoint must be behind a secure firewall.'); + } + + $callerToken = $this->accessTokenManager->findTokenByToken($clientToken->getToken()); + + if (!$callerToken) { + throw new AccessDeniedException('The access token must have a valid token.'); + } + + if (!in_array($callerToken->getClientId(), $this->allowedIntrospectionClients)) { + throw new AccessDeniedException('This access token is not autorised to do introspection.'); + } + } + + /** + * @return TokenInterface|null + */ + private function getToken(Request $request) + { + $formData = $this->processIntrospectionForm($request); + $tokenString = $formData->token; + $tokenTypeHint = $formData->token_type_hint; + + $tokenManagerList = []; + if (!$tokenTypeHint || 'access_token' === $tokenTypeHint) { + $tokenManagerList[] = $this->accessTokenManager; + } + if (!$tokenTypeHint || 'refresh_token' === $tokenTypeHint) { + $tokenManagerList[] = $this->refreshTokenManager; + } + + foreach ($tokenManagerList as $tokenManager) { + $token = $tokenManager->findTokenByToken($tokenString); + + if ($token) { + return $token; + } + } + } + + /** + * @return string|null + */ + private function getTokenType(TokenInterface $token) + { + if ($token instanceof AccessTokenInterface) { + return 'access_token'; + } elseif ($token instanceof RefreshTokenInterface) { + return 'refresh_token'; + } + + return null; + } + + /** + * @return string|null + */ + private function getUsername(TokenInterface $token) + { + $user = $token->getUser(); + if (!$user) { + return null; + } + + return $user->getUserName(); + } + + private function processIntrospectionForm(Request $request): Introspect + { + $formData = new Introspect(); + $form = $this->formFactory->create(IntrospectionFormType::class, $formData); + $form->handleRequest($request); + + if (!$form->isSubmitted() || !$form->isValid()) { + $errors = $form->getErrors(); + if (count($errors) > 0) { + throw new BadRequestHttpException((string) $errors); + } else { + throw new BadRequestHttpException('Introspection endpoint needs to have at least a "token" form parameter'); + } + } + return $form->getData(); + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index fb13f78c..8c06c179 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -79,6 +79,8 @@ public function getConfigTreeBuilder() $this->addAuthorizeSection($rootNode); $this->addServiceSection($rootNode); + $this->addTemplateSection($rootNode); + $this->addIntrospectionSection($rootNode); return $treeBuilder; } @@ -134,4 +136,38 @@ private function addServiceSection(ArrayNodeDefinition $node) ->end() ; } + + private function addTemplateSection(ArrayNodeDefinition $node) + { + $node + ->children() + ->arrayNode('template') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('engine')->defaultValue('twig')->end() + ->end() + ->end() + ->end() + ; + } + + private function addIntrospectionSection(ArrayNodeDefinition $node) + { + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('introspection') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('allowed_clients') + ->useAttributeAsKey('key') + ->treatNullLike([]) + ->prototype('variable')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/DependencyInjection/FOSOAuthServerExtension.php b/DependencyInjection/FOSOAuthServerExtension.php index 92773af5..e915fb4f 100644 --- a/DependencyInjection/FOSOAuthServerExtension.php +++ b/DependencyInjection/FOSOAuthServerExtension.php @@ -99,6 +99,8 @@ public function load(array $configs, ContainerBuilder $container) $authorizeFormDefinition = $container->getDefinition('fos_oauth_server.authorize.form'); $authorizeFormDefinition->setFactory([new Reference('form.factory'), 'createNamed']); } + + $this->loadIntrospection($config, $container, $loader); } /** @@ -140,6 +142,14 @@ protected function remapParametersNamespaces(array $config, ContainerBuilder $co } } + protected function loadIntrospection(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + $loader->load('introspection.xml'); + + $allowedClients = $config['introspection']['allowed_clients']; + $container->setParameter('fos_oauth_server.introspection.allowed_clients', $allowedClients); + } + protected function loadAuthorize(array $config, ContainerBuilder $container, XmlFileLoader $loader) { $loader->load('authorize.xml'); diff --git a/Form/Model/Introspect.php b/Form/Model/Introspect.php new file mode 100644 index 00000000..b419ed6a --- /dev/null +++ b/Form/Model/Introspect.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\OAuthServerBundle\Form\Model; + +use Symfony\Component\Validator\Constraints as Assert; + +class Introspect +{ + /** + * @var string + * @Assert\NotBlank() + */ + public $token; + + /** + * @var string + */ + public $token_type_hint; +} diff --git a/Form/Type/IntrospectionFormType.php b/Form/Type/IntrospectionFormType.php new file mode 100644 index 00000000..b169cf5a --- /dev/null +++ b/Form/Type/IntrospectionFormType.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\OAuthServerBundle\Form\Type; + +use FOS\OAuthServerBundle\Form\Model\Introspect; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class IntrospectionFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('token', HiddenType::class); + $builder->add('token_type_hint', ChoiceType::class, [ // can be `access_token`, `refresh_token` See https://tools.ietf.org/html/rfc7009#section-4.1.2 + 'choices' => [ + 'access_token' => 'access_token', + 'refresh_token' => 'refresh_token', + ] + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Introspect::class, + 'csrf_protection' => false, + ]); + } + + /** + * @return string + */ + public function getBlockPrefix() + { + return ''; + } +} diff --git a/Resources/config/introspection.xml b/Resources/config/introspection.xml new file mode 100644 index 00000000..da9d1cd6 --- /dev/null +++ b/Resources/config/introspection.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + %fos_oauth_server.introspection.allowed_clients% + + + + diff --git a/Resources/config/routing/introspection.xml b/Resources/config/routing/introspection.xml new file mode 100644 index 00000000..a36889ff --- /dev/null +++ b/Resources/config/routing/introspection.xml @@ -0,0 +1,12 @@ + + + + + + fos_oauth_server.controller.introspection:introspectAction + + + + diff --git a/Resources/doc/index.md b/Resources/doc/index.md index 65ac21c4..304a87a8 100644 --- a/Resources/doc/index.md +++ b/Resources/doc/index.md @@ -624,3 +624,5 @@ The `authorize` endpoint is at `/oauth/v2/auth` by default (see `Resources/confi [Adding Grant Extensions](adding_grant_extensions.md) [Custom DB Driver](custom_db_driver.md) + +[Introspection endpoint](introspection_endpoint.md) diff --git a/Resources/doc/introspection_endpoint.md b/Resources/doc/introspection_endpoint.md new file mode 100644 index 00000000..8cad3a80 --- /dev/null +++ b/Resources/doc/introspection_endpoint.md @@ -0,0 +1,76 @@ +Introspection endpoint +========================================= + +The OAuth 2.0 Token Introspection extension defines a protocol that returns information about an access token, intended to be used by resource servers or other internal servers. + +For more information, see [this explaination](https://www.oauth.com/oauth2-servers/token-introspection-endpoint/) or [the RFC 7662](https://tools.ietf.org/html/rfc7662). + +## Configuration + +Import the routing.yml configuration file in `app/config/routing.yml`: + +```yaml +# app/config/routing.yml + +fos_oauth_server_introspection: + resource: "@FOSOAuthServerBundle/Resources/config/routing/introspection.xml" +``` + +Add FOSOAuthServerBundle settings in `app/config/config.yml`: + +```yaml +fos_oauth_server: + introspection: + allowed_clients: + - 1_wUS0gjHdHyC2qeBL3u7RuIrIXClt6irL # an oauth client used only for token introspection. +``` + +The allowed clients MUST be clients as defined [here](index.md#creating-a-client) and SHOULD be used only for token introspection (otherwise a endpoint client might call the introspection endpoint with its valid token). + + +The introspection endpoint must be behind a firewall defined like this: + +```yaml +# app/config/security.yml +security: + firewalls: + oauth_introspect: + host: "%domain.oauth2%" + pattern: ^/oauth/v2/introspect + fos_oauth: true + stateless: true + anonymous: false +``` + +### Usage + +Then you can call the introspection endpoint like this: + +``` +POST /token_info +Host: authorization-server.com +Authorization: Bearer KvIu5v90GqgDctofFXP8npjC5DzMUkci + +token=SON4N82oVuRFykExk0iGTghihgOcI6bm +``` + +The JSON response will look like this if the token is inactive: + +```json +{ + "active": false +} +``` + +If the token is active, the response will look like this: + +```json +{ + "active": true, + "scope": "scope1 scope2", + "client_id": "2_HC1KF0UrawHx05AxgNEeKJF10giBUOHZ", + "username": "foobar", + "token_type": "access_token", + "exp": 1534921182 +} +```