next-drupal icon indicating copy to clipboard operation
next-drupal copied to clipboard

1.6 version incompatible with simple_oauth 6.x

Open thomjjames opened this issue 7 months ago • 1 comments

Package containing the bug

next (Drupal module)

Describe the bug

A clear and concise description of what the bug is.

simple_oauth 6.x contains breaking changes to the /oauth/token route which appear not to work with next-drupal 1.6 and maybe version 2.x as well. This results in previews returning 500 errors to the end user when DRUPAL_CLIENT_ID and DRUPAL_CLIENT_SECRET authentication.

The Drupal logs contain warnings like: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Hint: Check the `client_id` parameter.

simple_oauth/src/Controller/Oauth2Token.php:

public function token(Request $request): ResponseInterface {
    $server_request = $this->httpMessageFactory->createRequest($request);
    $server_response = new Response();
    $client_id = $request->get('client_id');
    $grant_type = $request->get('grant_type');
    $scopes = $request->get('scope');

    $lock_key = $this->createLockKey($request);

    try {
      // Try to acquire the lock.
      while (!$this->lock->acquire($lock_key)) {
        // If we can't acquire the lock, wait for it.
        if ($this->lock->wait($lock_key)) {
          // Timeout reached after 30 seconds.
          throw OAuthServerException::accessDenied('Request timed out. Could not acquire lock.');
        }
      }

      if (empty($client_id)) {
        throw OAuthServerException::invalidRequest('client_id');
      }
      $client_entity = $this->clientRepository->getClientEntity($client_id);
      if (empty($client_entity)) {
        throw OAuthServerException::invalidClient($server_request);
      }
      $client_drupal_entity = $client_entity->getDrupalEntity();

      // Omitting scopes is not allowed when dealing with client_credentials
      // and no default scopes are set.
      if (
        $grant_type === 'client_credentials' &&
        empty($scopes) &&
        $client_drupal_entity->get('scopes')->isEmpty()
      ) {
        throw OAuthServerException::invalidRequest('scope');
      }

      // Respond to the incoming request and fill in the response.
      $server = $this->authorizationServerFactory->get($client_drupal_entity);
      $response = $server->respondToAccessTokenRequest($server_request, $server_response);
    }
    catch (OAuthServerException $exception) {
      $this->logger->log(
        $exception->getCode() < 500 ? LogLevel::NOTICE : LogLevel::ERROR,
        $exception->getMessage() . ' Hint: ' . $exception->getHint() . '.'
      );
      $response = $exception->generateHttpResponse($server_response);
    }
    finally {
      // Release the lock.
      $this->lock->release($lock_key);
    }

    return $response;
  }

next-drupal package client:

async getAccessToken(
    opts?: DrupalClientAuthClientIdSecret
  ): Promise<AccessToken> {
    if (this.accessToken && this.accessTokenScope === opts?.scope) {
      return this.accessToken
    }

    if (!opts?.clientId || !opts?.clientSecret) {
      if (typeof this._auth === "undefined") {
        throw new Error(
          "auth is not configured. See https://next-drupal.org/docs/client/auth"
        )
      }
    }

    if (
      !isClientIdSecretAuth(this._auth) ||
      (opts && !isClientIdSecretAuth(opts))
    ) {
      throw new Error(
        `'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth`
      )
    }

    const clientId = opts?.clientId || this._auth.clientId
    const clientSecret = opts?.clientSecret || this._auth.clientSecret
    const url = this.buildUrl(opts?.url || this._auth.url || DEFAULT_AUTH_URL)

    if (
      this.accessTokenScope === opts?.scope &&
      this._token &&
      Date.now() < this.tokenExpiresOn
    ) {
      this._debug(`Using existing access token.`)
      return this._token
    }

    this._debug(`Fetching new access token.`)

    const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64")

    let body = `grant_type=client_credentials`

    if (opts?.scope) {
      body = `${body}&scope=${opts.scope}`

      this._debug(`Using scope: ${opts.scope}`)
    }

    const response = await this.fetch(url.toString(), {
      method: "POST",
      headers: {
        Authorization: `Basic ${basic}`,
        Accept: "application/json",
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body,
    })

    if (!response?.ok) {
      await this.handleJsonApiErrors(response)
    }

    const result: AccessToken = await response.json()

    this._debug(result)

    this.token = result

    this.accessTokenScope = opts?.scope

    return result
  }

Seems like the simple_oauth module expects client_id in the body and that scope is required but next-drupal sends the client_id & client_secret as basic auth and I believe scope is optional (?).

composer.json (https://git.drupalcode.org/project/next/-/blob/1.0.x/composer.json?ref_type=heads#L17) has "drupal/simple_oauth": "^5.0 || ^6.0" which allows the module to be upgraded to 6.x version.

Expected behavior

client_id should be passed in the body and perhaps the basic auth isn't needed although I believe it's ok in the OAuth2 spec (?).

Steps to reproduce:

  1. Set up a next-drupal project with preview mode as per https://v1-6.next-drupal.org/learn/preview-mode with the oauth authentication using the simple_oauth module (https://v1-6.next-drupal.org/learn/preview-mode/create-oauth-client)
  2. Then create content in an entity type rendered by Next (https://v1-6.next-drupal.org/learn/preview-mode/configure-content-types)
  3. View the content and the preview iframe should show a 500 error and the Drupal logs contain simple_oauth warning messages

Additional context

Had this happen on 2 sites when upgrading from simple_oauth 5.x to 6.x, the workaround we used was to switch to basic_auth authentication instead since the simple_oauth upgrade contained database updates which made it harder to rollback.

I guess this can be "fixed" either by updating getAccessToken or in the shorter term changing "drupal/simple_oauth": "^5.0 || ^6.0" to "drupal/simple_oauth": "^5.0"

This was my first dip into the inner workings of the module & package so apologies if any of my assumptions are incorrect :)

thomjjames avatar Mar 27 '25 14:03 thomjjames