Skip to content

Conversation

@nick-vanpraet
Copy link

PR to replace #257 as that one seems abandoned.

Adds support for the client_credentials grant type added in M4 mautic/mautic#9837

I kept as much of the logic that was used in the Oauth Auth class in as I could (the debugging, the weird query parameter access token thing that I'm pretty sure is a security concern, etc).

@cla-bot cla-bot bot added the cla-signed label Apr 7, 2022
@RCheesley RCheesley requested review from escopecz and kuzmany April 7, 2022 15:04
@codecov-commenter
Copy link

codecov-commenter commented Apr 7, 2022

Codecov Report

Attention: Patch coverage is 0% with 72 lines in your changes missing coverage. Please review.

Project coverage is 48.09%. Comparing base (7478f55) to head (c445d71).
Report is 127 commits behind head on main.

Files with missing lines Patch % Lines
lib/Auth/TwoLeggedOAuth2.php 0.00% 72 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #269      +/-   ##
============================================
- Coverage     51.45%   48.09%   -3.37%     
- Complexity      406      434      +28     
============================================
  Files            30       31       +1     
  Lines          1028     1100      +72     
============================================
  Hits            529      529              
- Misses          499      571      +72     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@kuzmany
Copy link
Member

kuzmany commented Apr 7, 2022

Don't understand create duplicate PR of #257

@RCheesley
Copy link
Member

@kuzmany guessing because there has been no response to feedback since September 2021!

@kuzmany
Copy link
Member

kuzmany commented Apr 7, 2022

Ok. But still would be good mention what is different between my version and this version.
We use that PR on production, works as expected and still don't see reason create new PR

@nick-vanpraet
Copy link
Author

Sure thing.

  • Requires baseUrl to be given
  • Implements accessTokenUpdated (for those who want it, gets set to true in requestAccessToken if nothing failed)
  • Implements getAccessTokenData method to return token, expires and token type
  • Checks the access token's expiry and presence in isAuthorized/validateAccessToken so code can re-use access tokens more easily
  • Implements getQueryParameters, straight copy from Oauth class. I imagine it's there to fix a bug, based on the comment above it.
  • Supports $this->_debug variable the same way Oauth class does

@RCheesley
Copy link
Member

It would be great to get a decision made on this @escopecz @kuzmany and merge it if we want to accept it in favour of the outdated PR based on the feedback provided above.

@RCheesley
Copy link
Member

@nick-vanpraet could you take a look at the failing tests for this PR please? Appreciate it was a while back, it'd be great to get it tested and merged!

@escopecz and @kuzmany we do still need to decide to merge this in favour of the older PR. It seems sensible to do so given the outlined additional functionality. Let's make a decision and get it merged?

@kuzmany
Copy link
Member

kuzmany commented Sep 3, 2024

@RCheesley we use #257 for our customers. It's from 2021 then I am not able to say what community need to merge. I see some Dennis comments, but cannot spend more time on it, only for maintenance to push it to branch. It's up to community. As usual I prefer less work for done.

@ctotech-pl
Copy link

ctotech-pl commented Nov 11, 2024

Tested PR: #269 in my build server and it is working.

Just 2x NOTICES need to be fixed:

[Notice] Message: strlen(): Passing null to parameter #1 ($string) of type string is deprecated File: /vendor/mautic/api-library/lib/Auth/TwoLeggedOAuth2.php Line:158
[Notice] Message: strlen(): Passing null to parameter #1 ($string) of type string is deprecated File: /vendor/mautic/api-library/lib/Auth/TwoLeggedOAuth2.php Line:165

File: mautic\api-library\lib\Auth\TwoLeggedOAuth2.php

  • at lines 158 and 165, change to use !empty( ... ) instead of strlen( ... )
public function validateAccessToken()
{
    $this->log('validateAccessToken()');

    //Check to see if token in session has expired
    if ( !empty($this->_access_token) && !empty($this->_expires) && $this->_expires < (time() + 10)) {
        $this->log('access token expired');

        return false;
    }

    //Check for existing access token
    if ( !empty($this->_access_token) ) {
        $this->log('has valid access token');

        return true;
    }

    //If there is no existing access token, it can't be valid
    return false;
}

This is a low risk merge:

  1. Introduces a new file to extend available functionality.
  2. Does not change or effect existing functionality or behavior.
  3. Adheres to abstract and follows code standards and exception handling.
  4. Improves README.MD with new documentation.

I vote to merge and released PR: #269 immediately, as it is a critical missing feature with low-impact.

@RCheesley RCheesley closed this Nov 12, 2024
@RCheesley RCheesley reopened this Nov 12, 2024
@RCheesley
Copy link
Member

RCheesley commented Nov 12, 2024

@nick-vanpraet looks like there's some code style fixes to be done here, would you take a look please?

We also need to have the test coverage increased as you can see from CodeCov. Let us know if you need some help with that!

@linuxd3v
Copy link

linuxd3v commented Jan 1, 2025

I just checked and, it's been 999 days (April 7, 2022 - December 31, 2024) since original PR creator nick-vanpraet last commented.
I mean, I strongly suspect they maybe (oh boy, how can I say this politely) - no longer interested in this feature as it has been (*checks watch) - a long long *** time.
so what is mautic policy here - someone has to create a fresh PR?

@escopecz
Copy link
Member

escopecz commented Jan 6, 2025

@linuxd3v yes, someone needs to take over and see the change being up with the project standards.

@nick-vanpraet nick-vanpraet force-pushed the two-legged-oauth2-support branch from c445d71 to d101d40 Compare January 7, 2025 10:16
@nick-vanpraet
Copy link
Author

I think mails are turned off for this repo or something. Anyway: rebased, implemented strlen/empty changes and fixed phpcs.

@escopecz
Copy link
Member

escopecz commented Jan 7, 2025

@nick-vanpraet thank you sir!

@linuxd3v can you please give it a review and a test before the merge?

Copy link
Member

@abhisekmazumdar abhisekmazumdar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this. It works as expected. This will be a great addition in the next release.

@RCheesley
Copy link
Member

@escopecz are we good to merge this with the successful test above? It'd be great to make the release, perhaps in time with 7.0?

@@ -0,0 +1,270 @@
<?php

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
declare(strict_types=1);

/**
* OAuth Client modified from https://code.google.com/p/simple-php-oauth/.
*/
class TwoLeggedOAuth2 extends AbstractAuth
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we could make this class final and add real property, param and return types to avoid BC breaks when doing this later on?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally absolutely despise final. If a developer using this package wants to make a small tweak to this class for a specific setup, they would have to copy the whole thing. If that is what we want to force, I can add it. But there was an issue I was working on in Mautic that could have been very easily and cleanly solved if a certain Symfony class did not have final set.

Typehinting fo sho though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with @nick-vanpraet

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inheritance creates a multi-layered mess. A good example are Mautic controllers and models.

I suggest to google "composition over inheritance" to get good examples why composition is way better practice.

If the architecture is not allowing to change a class then Symfony allows you to decorate the service which is not the point here but it is for Mautic.

Plus, a final class is easier to maintain for a library like this one as developers don't have to think about all the ways how users could have inherited the class and what it could break for them.

I'm not going to block this with these suggestions PR if it gets a second approval as I'm not using this library.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure but IIRC I didn't want to replace anything I just wanted to use the existing logic for something instead of having to re-invent the wheel, just with a minor tweak.

Anyway I don't think it's up to package developers to care if someone else inherits their class. They either do or they don't, we're not their mother. Add an @internal annotation and let them decide if they want to risk it.

Speaking of, I'll add an @internal annotation at least.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for OAuth2 Client Credentials grant type (two-legged OAuth) to the Mautic API library. This authentication method is designed for application-to-application scenarios where actions are triggered by the application itself rather than by user interaction.

Changes:

  • Introduces new TwoLeggedOAuth2 authentication class implementing client credentials flow
  • Adds comprehensive documentation and usage examples in README.md for the new authentication method
  • Implements token validation, refresh, and debugging capabilities consistent with existing OAuth implementation

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

File Description
lib/Auth/TwoLeggedOAuth2.php New authentication class implementing OAuth2 client credentials grant type with token management, validation, and request preparation logic
README.md Adds documentation section explaining two-legged OAuth2 authentication with complete code examples for setup and usage

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

use Mautic\Exception\RequiredParameterMissingException;

/**
* @internal OAuth Client modified from https://code.google.com/p/simple-php-oauth/.
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @internal annotation suggests this class should not be used by external code, but it's documented in the README.md as a public authentication method for users to adopt. This is inconsistent with the other Auth classes (OAuth, BasicAuth) which don't have @internal annotations. Consider removing @internal if this is meant to be a public API, or add it to all Auth classes if they should all be considered internal.

Suggested change
* @internal OAuth Client modified from https://code.google.com/p/simple-php-oauth/.
* OAuth Client modified from https://code.google.com/p/simple-php-oauth/.

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +140
$publicKey = '';
$secretKey = '';
$callback = '';

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These variables are defined but never used in the example. They should be removed since they don't apply to the Two-Legged OAuth2 flow (Client Credentials grant), which doesn't require a callback URL or use the same key naming conventions.

Suggested change
$publicKey = '';
$secretKey = '';
$callback = '';

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +89
/**
* Check to see if the access token was updated.
*
* @return bool
*/
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @return annotation is redundant since the method already has a return type declaration. The docblock return type annotation can be removed to reduce redundancy and follow modern PHP practices.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,241 @@
<?php
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is missing the standard copyright header that all other files in lib/Auth/ include. Consider adding the standard header with copyright, author, link, and license information for consistency with the rest of the codebase.

Suggested change
<?php
<?php
/*
* @copyright 2014 Mautic, Inc.
* @author Mautic
* @link https://www.mautic.org
*
* @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html
*/

Copilot uses AI. Check for mistakes.

/**
* @param string $url
* @param array $method
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @param annotations are incorrect. The parameter $method is documented as type 'array' but is actually a string, and $url has type string but is not type-hinted in the signature. Consider correcting the @param annotations to match the actual method signature or adding proper type hints to the method parameters.

Suggested change
* @param array $method
* @param array $headers
* @param array $parameters
* @param string $method
* @param array $settings

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +241
class TwoLeggedOAuth2 extends AbstractAuth
{
/**
* Access token URL.
*/
protected string $_access_token_url;

/**
* Access token returned by OAuth server.
*/
protected ?string $_access_token;

/**
* Consumer or client key.
*/
protected string $_client_id;

/**
* Consumer or client secret.
*/
protected string $_client_secret;

/**
* Unix timestamp for when token expires.
*/
protected ?int $_expires;

/**
* OAuth2 token type.
*/
protected ?string $_token_type = 'bearer';

/**
* Set to true if the access token was updated.
*/
protected bool $_access_token_updated = false;

/**
* @param string|null $baseUrl URL of the Mautic instance
*/
public function setup(
?string $baseUrl = null,
?string $clientKey = null,
?string $clientSecret = null,
?string $accessToken = null,
?int $accessTokenExpires = null,
): void {
if (empty($clientKey) || empty($clientSecret)) {
// Throw exception if the required parameters were not found
$this->log('parameters did not include clientkey and/or clientSecret');
throw new RequiredParameterMissingException('One or more required parameters was not supplied. Both clientKey and clientSecret required!');
}

if (empty($baseUrl)) {
// Throw exception if the required parameters were not found
$this->log('parameters did not include baseUrl');
throw new RequiredParameterMissingException('One or more required parameters was not supplied. baseUrl required!');
}

$this->_client_id = $clientKey;
$this->_client_secret = $clientSecret;
$this->_access_token = $accessToken;
$this->_access_token_url = $baseUrl.'/oauth/v2/token';

if (!empty($accessToken)) {
$this->setAccessTokenDetails([
'access_token' => $accessToken,
'expires' => $accessTokenExpires,
]);
}
}

/**
* Check to see if the access token was updated.
*
* @return bool
*/
public function accessTokenUpdated()
{
return $this->_access_token_updated;
}

/**
* Returns access token data.
*/
public function getAccessTokenData(): array
{
return [
'access_token' => $this->_access_token,
'expires' => $this->_expires,
'token_type' => $this->_token_type,
];
}

public function isAuthorized(): bool
{
$this->log('isAuthorized()');

return $this->validateAccessToken();
}

/**
* Set an existing/already retrieved access token.
*
* @return $this
*/
public function setAccessTokenDetails(array $accessTokenDetails): static
{
$this->_access_token = $accessTokenDetails['access_token'] ?? null;
$this->_expires = isset($accessTokenDetails['expires']) ? (int) $accessTokenDetails['expires'] : null;

return $this;
}

/**
* Validate existing access token.
*/
public function validateAccessToken(): bool
{
$this->log('validateAccessToken()');

// Check to see if token in session has expired (or will in a few seconds)
if (!empty($this->_access_token) && !empty($this->_expires) && $this->_expires < (time() + 10)) {
$this->log('access token expired');

return false;
}

// Check for existing access token
if (!empty($this->_access_token)) {
$this->log('has valid access token');

return true;
}

// If there is no existing access token, it can't be valid
return false;
}

/**
* @param bool $isPost
* @param array $parameters
*/
protected function getQueryParameters($isPost, $parameters): array
{
$query = parent::getQueryParameters($isPost, $parameters);

if (isset($parameters['file'])) {
// Mautic's OAuth2 server does not recognize multipart forms so we have to append the access token as part of the URL
$query['access_token'] = $parameters['access_token'];
}

return $query;
}

/**
* @param string $url
* @param array $method
*/
protected function prepareRequest($url, array $headers, array $parameters, $method, array $settings): array
{
if ($this->isAuthorized()) {
$headers = array_merge($headers, ['Authorization: Bearer '.$this->_access_token]);
}

return [$headers, $parameters];
}

/**
* Request access token.
*
* @throws IncorrectParametersReturnedException|\Mautic\Exception\UnexpectedResponseFormatException
*/
public function requestAccessToken(): bool
{
$this->log('requestAccessToken()');

$parameters = [
'client_id' => $this->_client_id,
'client_secret' => $this->_client_secret,
'grant_type' => 'client_credentials',
];

// Make the request
$params = $this->makeRequest($this->_access_token_url, $parameters, 'POST');

// Add the token to session
if (is_array($params)) {
if (isset($params['access_token']) && isset($params['expires_in'])) {
$this->log('access token set as '.$params['access_token']);

$this->_access_token = $params['access_token'];
$this->_expires = time() + (int) $params['expires_in'];
$this->_token_type = (isset($params['token_type'])) ? $params['token_type'] : null;
$this->_access_token_updated = true;

if ($this->_debug) {
$_SESSION['oauth']['debug']['tokens']['access_token'] = $params['access_token'];
$_SESSION['oauth']['debug']['tokens']['expires_in'] = $params['expires_in'];
$_SESSION['oauth']['debug']['tokens']['token_type'] = $params['token_type'];
}

return true;
}
}

$this->log('response did not have an access token');

if ($this->_debug) {
$_SESSION['oauth']['debug']['response'] = $params;
}

if (is_array($params)) {
if (isset($params['errors'])) {
$errors = [];
foreach ($params['errors'] as $error) {
$errors[] = $error['message'];
}
$response = implode('; ', $errors);
} else {
$response = print_r($params, true);
}
} else {
$response = $params;
}

throw new IncorrectParametersReturnedException('Incorrect access token parameters returned: '.$response);
}
}
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new authentication class lacks test coverage. Consider adding tests similar to BasicAuthTest.php that verify parameter validation, setup method error handling, and basic authorization flow. At minimum, tests should cover missing client_id, missing client_secret, missing baseUrl, and successful token request scenarios.

Copilot uses AI. Check for mistakes.
if ($this->_debug) {
$_SESSION['oauth']['debug']['tokens']['access_token'] = $params['access_token'];
$_SESSION['oauth']['debug']['tokens']['expires_in'] = $params['expires_in'];
$_SESSION['oauth']['debug']['tokens']['token_type'] = $params['token_type'];
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code accesses $params['token_type'] directly without checking if it exists first, which could result in an undefined array key warning. This is inconsistent with line 206 where the same key is accessed using a ternary operator with an isset check. Consider using the same pattern here for consistency and to avoid warnings.

Suggested change
$_SESSION['oauth']['debug']['tokens']['token_type'] = $params['token_type'];
$_SESSION['oauth']['debug']['tokens']['token_type'] = isset($params['token_type']) ? $params['token_type'] : null;

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +165
$query = parent::getQueryParameters($isPost, $parameters);

if (isset($parameters['file'])) {
// Mautic's OAuth2 server does not recognize multipart forms so we have to append the access token as part of the URL
$query['access_token'] = $parameters['access_token'];
}

return $query;
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getQueryParameters override appends the OAuth2 access_token into the URL query string for file uploads, which exposes bearer tokens in request URLs and therefore in web server/proxy logs and referrers, making them much easier to leak or exfiltrate. An attacker with access to logs or intermediaries that record URLs could steal these tokens and impersonate the client against the Mautic API. Instead of placing access_token in the query string, ensure authentication is conveyed via the Authorization header (or another non-logged channel) and, if the server truly cannot handle multipart auth headers, consider fixing the server behavior or using a dedicated endpoint that does not log full URLs.

Suggested change
$query = parent::getQueryParameters($isPost, $parameters);
if (isset($parameters['file'])) {
// Mautic's OAuth2 server does not recognize multipart forms so we have to append the access token as part of the URL
$query['access_token'] = $parameters['access_token'];
}
return $query;
// Delegate to parent without appending access tokens to the query string
return parent::getQueryParameters($isPost, $parameters);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

8 participants