Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,56 @@ try {
}
```

### Using Two-Legged Authentication (Oauth2 Client Credentials) Instead
The above method uses authorization code flow for Oauth2. Client Credentials is the preferred method of
authentication when the use-case is application to application, where any actions
are triggered by the application itself and not a user taking an action (e.g. cleanup during cron).

```php
<?php

// Bootup the Composer autoloader
include __DIR__ . '/vendor/autoload.php';

use Mautic\Auth\ApiAuth;

session_start();

$publicKey = '';
$secretKey = '';
$callback = '';

Comment on lines +137 to +140
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.
// ApiAuth->newAuth() will accept an array of Auth settings
$settings = [
'AuthMethod' => 'TwoLeggedOAuth2',
'clientKey' => '',
'clientSecret' => '',
'baseUrl' => '',
];

/*
// If you already have the access token, et al, pass them in as well to prevent the need for reauthorization
$settings['accessToken'] = $accessToken;
$settings['accessTokenExpires'] = $accessTokenExpires; //UNIX timestamp
*/

// Initiate the auth object
$initAuth = new ApiAuth();
$auth = $initAuth->newAuth($settings, $settings['AuthMethod']);

if (!$auth->isAuthorized()) {
$auth->requestAccessToken();
// $accessTokenData will have the following keys:
// access_token, expires, token_type
$accessTokenData = $auth->getAccessTokenData();

//store access token data however you want
}

// Nothing else to do ... It's ready to use.
// Just pass the auth object to the API context you are creating.
```

### Using Basic Authentication Instead
Instead of messing around with OAuth, you may simply elect to use BasicAuth instead.

Expand Down
241 changes: 241 additions & 0 deletions lib/Auth/TwoLeggedOAuth2.php
Original file line number Diff line number Diff line change
@@ -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.

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);

declare(strict_types=1);

namespace Mautic\Auth;

use Mautic\Exception\IncorrectParametersReturnedException;
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.
*/
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.

{
/**
* 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
*/
Comment on lines +85 to +89
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.
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;
Comment on lines +158 to +165
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.
}

/**
* @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.
*/
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'];
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.
}

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);
}
}
Comment on lines +13 to +241
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.