Return invalid_scope error when refresh token second time.

Created on 27 February 2025, about 2 months ago

Problem/Motivation

Return invalid_scope error when refresh token second time.

{
  "error": "invalid_scope",
  "error_description": "The requested scope is invalid, unknown, or malformed",
  "hint": "Check the `role_authenticated` scope"
}

This problem is caused by thephpleague/oauth2-server, RefreshTokenGrant doesn't provides a user identifier.

https://github.com/thephpleague/oauth2-server/blob/00323013403e1a1e0f424...

And ScopeRepository of simple_oauth return empty of scope directly if no user identifier is provided.

https://git.drupalcode.org/project/simple_oauth/-/blob/6.0.x/src/Reposit...

Steps to reproduce

1, Request a access_token use any grant type.
2, Refresh the access_token through the refresh_token grant type.
3, Refresh the access_token again through the refresh_token grant type, which refresh token was generate in step 2.
4, We can see the 400 with :

{
  "error": "invalid_scope",
  "error_description": "The requested scope is invalid, unknown, or malformed",
  "hint": "Check the `role_authenticated` scope"
}

Proposed resolution

Change ScopeRepository to return method inputed scopes as default if the grant type is refresh_token even no user identifier is provided.

Remaining tasks

User interface changes

API changes

Data model changes

πŸ› Bug report
Status

Needs work

Version

6.0

Component

Code

Created by

πŸ‡¨πŸ‡³China 司南

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

  • Issue created by @司南
  • Pipeline finished with Success
    about 2 months ago
    Total: 345s
    #435340
  • πŸ‡¨πŸ‡³China 司南

    Feel free to use this patch.

  • πŸ‡³πŸ‡±Netherlands bojan_dev

    I can't reproduce this, it's not possible to refresh the token for any grant type, it is only applicable for the "Authorization Code".
    Can you please provide more info about the configured consumer, scope and authorize request?

    By the way: please don't set the issue status to reviewed, a peer review should not be done on your own work.

  • πŸ‡³πŸ‡±Netherlands idebr

    Can you check the Grant type 'Refresh Token' is enable on your Consumer edit form?

  • πŸ‡¨πŸ‡³China 司南

    I use a custom grant type which like the deprecated grant type password.

  • πŸ‡¨πŸ‡³China 司南

    Consumer created by the lines:

      // Create client of oauth2 service.
      Consumer::create([
        'client_id' => 'xxx_app',
        'label' => 'XXX App',
        'description' => 'Oauth2 client for XXX.',
        'is_default' => FALSE,
        'grant_types' => [
          'authorization_code',
          'refresh_token',
          'password',
          'sms',
        ],
        'secret' => '123',
        'confidential' => TRUE,
        'redirect' => 'https://app.xxx.cn/',
        'access_token_expiration' => 300,
        'refresh_token_expiration' => 1209600,
      ])->save();
    
    
    namespace Drupal\xxx\Plugin\Oauth2Grant;
    
    use Drupal\Core\Config\ConfigFactoryInterface;
    use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
    use Drupal\consumers\Entity\Consumer;
    use Drupal\simple_oauth\Plugin\Oauth2GrantBase;
    use League\OAuth2\Server\Grant\GrantTypeInterface;
    use League\OAuth2\Server\Grant\PasswordGrant;
    use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
    use League\OAuth2\Server\Repositories\UserRepositoryInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    
    
    /**
     * @Oauth2Grant(
     *   id = "password",
     *   label = @Translation("Password")
     * )
     */
    class Password extends Oauth2GrantBase implements ContainerFactoryPluginInterface {
    
      /**
       * @var \League\OAuth2\Server\Repositories\UserRepositoryInterface
       */
      protected $userRepository;
    
      /**
       * @var \League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface
       */
      protected $refreshTokenRepository;
    
      /**
       * The config factory.
       *
       * @var \Drupal\Core\Config\ConfigFactoryInterface
       */
      protected $configFactory;
    
      /**
       * Class constructor.
       */
      public function __construct(array $configuration, $plugin_id, $plugin_definition, UserRepositoryInterface $user_repository, RefreshTokenRepositoryInterface $refresh_token_repository, ConfigFactoryInterface $config_factory) {
        parent::__construct($configuration, $plugin_id, $plugin_definition);
        $this->userRepository = $user_repository;
        $this->refreshTokenRepository = $refresh_token_repository;
        $this->configFactory = $config_factory;
      }
    
      /**
       * {@inheritdoc}
       */
      public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
        return new static(
          $configuration,
          $plugin_id,
          $plugin_definition,
          $container->get('user_phone.oauth2.repositories.user'),
          $container->get('simple_oauth.repositories.refresh_token'),
          $container->get('config.factory')
        );
      }
    
      /**
       * {@inheritdoc}
       */
      public function getGrantType(Consumer $client): GrantTypeInterface {
        $refresh_token_enabled = $this->isRefreshTokenEnabled($client);
    
        /** @var \Drupal\simple_oauth\Repositories\OptionalRefreshTokenRepositoryInterface $refresh_token_repository */
        $refresh_token_repository = $this->refreshTokenRepository;
        if (!$refresh_token_enabled) {
          $refresh_token_repository->disableRefreshToken();
        }
        $grant_type = $this->createGrantType();
    
        if ($refresh_token_enabled) {
          $refresh_token = !$client->get('refresh_token_expiration')->isEmpty ? $client->get('refresh_token_expiration')->value : 1209600;
          $refresh_token_ttl = new \DateInterval(
            sprintf('PT%dS', $refresh_token)
          );
          $grant_type->setRefreshTokenTTL($refresh_token_ttl);
        }
        return $grant_type;
      }
    
      /**
       * Create Gran type object.
       *
       * @return \League\OAuth2\Server\Grant\GrantTypeInterface
       *   The created object.
       */
      protected function createGrantType(): GrantTypeInterface {
        return new PasswordGrant($this->userRepository, $this->refreshTokenRepository);
      }
    
      /**
       * Checks if refresh token is enabled on the client.
       *
       * @param \Drupal\consumers\Entity\Consumer $client
       *   The consumer entity.
       *
       * @return bool
       *   Returns boolean.
       */
      protected function isRefreshTokenEnabled(Consumer $client): bool {
        foreach ($client->get('grant_types')->getValue() as $grant_type) {
          if ($grant_type['value'] === 'refresh_token') {
            return TRUE;
          }
        }
        return FALSE;
      }
    
    }
    
    
    
    namespace Drupal\user_phone\Plugin\Oauth2Grant;
    
    use Drupal\user_phone\Oauth2\Grant\SMSGrant;
    use League\OAuth2\Server\Grant\GrantTypeInterface;
    
    /**
     * Add a custom grant type to allow PhoneNumber + SMS message authentication.
     *
     * @Oauth2Grant(
     *   id = "sms",
     *   label = @Translation("SMS")
     * )
     */
    class SMS extends Password {
    
      /**
       * {@inheritDoc}
       */
      protected function createGrantType(): GrantTypeInterface {
        return new SMSGrant(
          $this->userRepository,
          $this->refreshTokenRepository,
        );
      }
    
    }
    
    
    namespace Drupal\xxx\Oauth2\Grant;
    
    use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
    use Drupal\Component\Plugin\Exception\PluginNotFoundException;
    use Drupal\Core\Extension\MissingDependencyException;
    use Drupal\Core\StringTranslation\StringTranslationTrait;
    use Drupal\simple_oauth\Entities\UserEntity;
    use League\OAuth2\Server\Entities\ClientEntityInterface;
    use League\OAuth2\Server\Entities\UserEntityInterface;
    use League\OAuth2\Server\Exception\OAuthServerException;
    use League\OAuth2\Server\Grant\PasswordGrant;
    use League\OAuth2\Server\RequestEvent;
    use Psr\Http\Message\ServerRequestInterface;
    
    /**
     * SMS grant class.
     */
    class SMSGrant extends PasswordGrant {
    
      use StringTranslationTrait;
    
      /**
       * {@inheritdoc}
       *
       * @throws \Drupal\Core\Extension\MissingDependencyException
       *   User module must be enabled.
       */
      protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface {
        $country = $this->getRequestParameter('country', $request);
        if (is_null($country)) {
          throw OAuthServerException::invalidRequest('country');
        }
    
        $number = $this->getRequestParameter('number', $request);
        if (is_null($number)) {
          throw OAuthServerException::invalidRequest('number');
        }
    
        $code = $this->getRequestParameter('code', $request);
        if (is_null($code)) {
          throw OAuthServerException::invalidRequest('code');
        }
    
        /** @var \Drupal\mobile_number\MobileNumberUtilInterface $util */
        $util = \Drupal::service('mobile_number.util');
        $mobileNumber = $util->getMobileNumber($number, $country);
        if ($mobileNumber) {
    
          // Check whether the phone number have been registered.
          try {
            $users = \Drupal::entityTypeManager()
              ->getStorage('user')
              ->loadByProperties(['phone' => $util->getCallableNumber($mobileNumber)]);
          }
          catch (InvalidPluginDefinitionException | PluginNotFoundException $e) {
            throw new MissingDependencyException('Can not find user entity storage, user module of drupal core is necessary.');
          }
    
          if (count($users) > 1) {
            // Multiple user have been found.
            throw OAuthServerException::invalidRequest('number',
              'Multi accounts found for the mobile number, please contact administrator.');
          }
          elseif (count($users) === 0) {
            // No user can be found.
            throw OAuthServerException::invalidRequest('number',
              'Phone number not exist.');
          }
    
          $user = new UserEntity();
          $user->setIdentifier(reset($users)->id());
    
          /** @var \Drupal\user_phone\SmsCodeVerifierInterface $phone_verify */
          $phone_verify = \Drupal::service('user_phone.sms_code_verifier');
          if ($phone_verify->verify($util->getCallableNumber($mobileNumber), $code)) {
            return $user;
          }
        }
    
        $this->getEmitter()
          ->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
        throw OAuthServerException::invalidCredentials();
      }
    
      /**
       * {@inheritdoc}
       */
      public function getIdentifier(): string {
        return 'sms';
      }
    
    }
    
    

    Scope config entity I use:

    langcode: en
    status: true
    dependencies: {  }
    id: role_anonymous
    name: role_anonymous
    description: 'Role scope for anonymous.'
    grant_types:
      refresh_token:
        status: true
        description: ''
      authorization_code:
        status: true
        description: ''
      client_credentials:
        status: true
        description: ''
      password:
        status: true
        description: ''
      sms:
        status: true
        description: ''
    umbrella: false
    parent: _none
    granularity_id: role
    granularity_configuration:
      role: anonymous
    
    langcode: en
    status: true
    dependencies: {  }
    id: role_authenticated
    name: role_authenticated
    description: 'Role scope for authenticated.'
    grant_types:
      refresh_token:
        status: true
        description: ''
      authorization_code:
        status: true
        description: ''
      client_credentials:
        status: true
        description: ''
      password:
        status: true
        description: ''
      sms:
        status: true
        description: ''
    umbrella: false
    parent: _none
    granularity_id: role
    granularity_configuration:
      role: authenticated
    
  • πŸ‡¨πŸ‡³China 司南

    Why is it not possible to refresh the token for any grant type, it is only applicable for the "Authorization Code"?

    Is there any oauth2 protocol pointting this?

  • πŸ‡³πŸ‡±Netherlands bojan_dev

    I was assuming you were only using the grant types that are provided by simple_oauth, based on that assumption that would mean that only the Authorization code grant type supports refresh tokens. But I see you are using the Password grant, which is not supported by simple_oauth: 6.0 due to the following reason: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#s...

    You could take a look in 5.2 which still supports the password grant.

  • πŸ‡ΊπŸ‡ΈUnited States m.stenta

    @司南 I'm not sure if this is related, but I just ran into this issue with the simple_oauth_password_grant module after updating to simple_oauth 6.0.0-beta10: πŸ› Refreshed access_token is missing scope with league/oauth2-server ^9 Active

  • πŸ‡ΊπŸ‡ΈUnited States m.stenta

    I just tested the change proposed by @司南 and it fixes the issue in πŸ› Refreshed access_token is missing scope with league/oauth2-server ^9 Active !

    So maybe it's all the same bug in simple_oauth after all.

    Setting this back to Needs Review.

    @bojan_dev please see the steps to reproduce and screenshots in πŸ› Refreshed access_token is missing scope with league/oauth2-server ^9 Active - even though it is in the other module's issue queue, the fix proposed by @司南 fixes what I am seeing.

    It makes me wonder if Authorization code grant types are affected too - I haven't tested them. It's very easy to test the password grant issue and see the missing scope in the Simple OAuth UI.

  • First commit to issue fork.
  • πŸ‡³πŸ‡±Netherlands bojan_dev

    It appears the following change in thephpleague/oauth2-server is the reason for the BC: https://github.com/thephpleague/oauth2-server/pull/1094.

    finalizeScopes is now being called in the RefreshTokenGrant, which does not set the $userIdentifier, this means that no scopes are being returned. This has impact on all grant types that support refresh tokens. Sorry that I missed this, also I have read your comments @m.stenta on 🌱 Move to thephpleague/oauth2-server 9.0 Active , those are valid points I will definitely make a major version release next time.

    Merging MR !176 and will roll out a new stable release.

  • πŸ‡ΊπŸ‡ΈUnited States m.stenta

    Thank you @bojan_dev! I appreciate everything you do for this module (and Drupal generally)! I am truly grateful!

  • Automatically closed - issue fixed for 2 weeks with no activity.

Production build 0.71.5 2024