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 10 months ago • 4 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

Getting the same error on next-drupal v2.0 using the Pages Router. Below is how I set up my Drupal client:

const baseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL as string
const clientId = process.env.OAUTH_CLIENT_ID as string
const clientSecret = process.env.OAUTH_CLIENT_SECRET as string

export const drupal = new NextDrupalPages(baseUrl, {
  apiPrefix: '/api/v1',
  auth: {
    clientId,
    clientSecret,
  },
  withAuth: true,
})

Image

gri-ghawsen avatar Jun 06 '25 02:06 gri-ghawsen

Do we have any latest update on this issue?

mareaswa avatar Jul 15 '25 17:07 mareaswa

We were able to fix this in our case:

  1. we updated simple_oauth from version 5 to 6, and you do need to go and check the configuration (in our case, the new scope config entity type did not have the role property set after the update, adding it made it work

  2. we also had this bit of custom code implementing hook__next_simple_oauth_private_claims_alter that suddenly failed badly because it assumed that the token would have a user id in it, and for this type of grant it does not have it anymore. (so we added an if to check if the user id is defined).

hope this helps :-)

vermario avatar Oct 09 '25 12:10 vermario