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
+}
+```