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

CommentFileSizeAuthor
#4 issues-3509299.patch911 bytes司南
Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

司南 created an issue. See original summary.

司南’s picture

Status: Needs work » Reviewed & tested by the community
司南’s picture

StatusFileSize
new911 bytes

Feel free to use this patch.

bojan_dev’s picture

Status: Reviewed & tested by the community » Postponed (maintainer needs more info)

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.

idebr’s picture

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

司南’s picture

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

司南’s picture

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('xxx.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\xxx\Plugin\Oauth2Grant;

use Drupal\xxx\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\xxx\SmsCodeVerifierInterface $phone_verify */
      $phone_verify = \Drupal::service('xxx.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

Generate access token:

POST {{base_url}}/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=sms&client_id={{client_id}}&client_secret={{client_secret}}&country={{phone_country}}&number={{phone_number}}&code={{phone_code}}&scope=role_authenticated

> {%
  client.global.set('ACCESS_TOKEN', response.body.access_token);
  client.global.set('REFRESH_TOKEN', response.body.refresh_token);
%}

Refresh the token: (Do this twice cause the error)

POST {{base_url}}/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token={{REFRESH_TOKEN}}&client_id={{client_id}}&client_secret={{client_secret}}&scope=role_authenticated

> {%
  client.global.set('ACCESS_TOKEN', response.body.access_token);
  client.global.set('REFRESH_TOKEN', response.body.refresh_token);
%}
司南’s picture

#5 @bojan_dev

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?

bojan_dev’s picture

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.

m.stenta’s picture

@司南 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: #3511488: Refreshed access_token is missing scope with league/oauth2-server ^9

m.stenta’s picture

Status: Postponed (maintainer needs more info) » Needs review
Related issues: +#3511488: Refreshed access_token is missing scope with league/oauth2-server ^9

I just tested the change proposed by @司南 and it fixes the issue in #3511488: Refreshed access_token is missing scope with league/oauth2-server ^9!

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 #3511488: Refreshed access_token is missing scope with league/oauth2-server ^9 - 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.

chfoidl made their first commit to this issue’s fork.

bojan_dev’s picture

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 #3277256: Move to thephpleague/oauth2-server 9.0, 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.

  • bojan_dev committed 418ede91 on 6.0.x authored by 司南
    Issue #3509299: Do return empty scopes directely when the grant type is...
bojan_dev’s picture

Status: Needs review » Fixed
bojan_dev’s picture

m.stenta’s picture

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

Status: Fixed » Closed (fixed)

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