-
Notifications
You must be signed in to change notification settings - Fork 128
Add support for client credentials grant #269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add support for client credentials grant #269
Conversation
Codecov ReportAttention: Patch coverage is
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. |
|
Don't understand create duplicate PR of #257 |
|
@kuzmany guessing because there has been no response to feedback since September 2021! |
|
Ok. But still would be good mention what is different between my version and this version. |
|
Sure thing.
|
|
@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? |
|
@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. |
|
Tested PR: #269 in my build server and it is working. Just 2x NOTICES need to be fixed: File:
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:
I vote to merge and released PR: #269 immediately, as it is a critical missing feature with low-impact. |
|
@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! |
|
I just checked and, it's been 999 days (April 7, 2022 - December 31, 2024) since original PR creator nick-vanpraet last commented. |
|
@linuxd3v yes, someone needs to take over and see the change being up with the project standards. |
c445d71 to
d101d40
Compare
|
I think mails are turned off for this repo or something. Anyway: rebased, implemented strlen/empty changes and fixed phpcs. |
|
@nick-vanpraet thank you sir! @linuxd3v can you please give it a review and a test before the merge? |
abhisekmazumdar
left a comment
There was a problem hiding this 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.
|
@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 | |||
|
|
|||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| declare(strict_types=1); | |
| /** | ||
| * OAuth Client modified from https://code.google.com/p/simple-php-oauth/. | ||
| */ | ||
| class TwoLeggedOAuth2 extends AbstractAuth |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree with @nick-vanpraet
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Some would require changes to the parent, out of scope for this PR
There was a problem hiding this 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
TwoLeggedOAuth2authentication 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/. |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| * @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/. |
| $publicKey = ''; | ||
| $secretKey = ''; | ||
| $callback = ''; | ||
|
|
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| $publicKey = ''; | |
| $secretKey = ''; | |
| $callback = ''; |
| /** | ||
| * Check to see if the access token was updated. | ||
| * | ||
| * @return bool | ||
| */ |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| @@ -0,0 +1,241 @@ | |||
| <?php | |||
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| <?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 | |
| */ |
|
|
||
| /** | ||
| * @param string $url | ||
| * @param array $method |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| * @param array $method | |
| * @param array $headers | |
| * @param array $parameters | |
| * @param string $method | |
| * @param array $settings |
| 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); | ||
| } | ||
| } |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| 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']; |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| $_SESSION['oauth']['debug']['tokens']['token_type'] = $params['token_type']; | |
| $_SESSION['oauth']['debug']['tokens']['token_type'] = isset($params['token_type']) ? $params['token_type'] : null; |
| $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; |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| $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); |
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).