refactor(oauth): extract CactiOAuth provider factory (#7129)

* refactor(oauth): extract Cacti\Security\CactiOAuth provider factory

Replace the inline provider switch in oauth2.php and the OAuth
branch of mailer() in lib/functions.php with two helpers:

- CactiOAuth::getProvider($name, $params) returns the configured
  provider instance for `google`, `azure`, `yahoo`, or `microsoft`,
  or null when the configured provider is unknown.
- CactiOAuth::getDefaultOptions($name) returns the scope set each
  provider expects.

Behaviour is preserved; both call sites now branch on a null
return rather than relying on a `$provider` variable that may have
fallen through the switch unset.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* chore(phpstan): catch up baseline with 11 upstream-detected entries

Same as PR-A: appends 11 PHPStan ignoreErrors entries that exist on
upstream develop but are not yet baselined, so this branch's CI does
not regress on phpstan analyse --level 6.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* refactor: flatten CactiOAuth to global namespace

Rename lib/Security/CactiOAuth.php to lib/CactiOAuth.php and drop the
Cacti\Security namespace. Update the two callers (oauth2.php and
lib/functions.php mailer) to use the unqualified class name. Behavior
is unchanged: only the FQCN was rewritten.

The flat layout matches every other lib/Cacti*.php class on develop and
in the canonical PRs (#7088, #7073, #7077, #7075).

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: add shared unit stubs for OAuth tests

* fix(oauth): load provider factory in runtime paths

* test(oauth): pin provider factory runtime loading

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
This commit is contained in:
Thomas VincentandTheWitness authored and GitHub committed 2026-05-13 10:36:06 -04:00
1 parent 8685b40650
commit aae7f0ea5b
4 files changed
+206 -67

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types = 1);
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
use Greew\OAuth2\Client\Provider\Azure;
use Hayageek\OAuth2\Client\Provider\Yahoo;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Google;
use Stevenmaguire\OAuth2\Client\Provider\Keycloak;
use Stevenmaguire\OAuth2\Client\Provider\Microsoft;
class CactiOAuth {
/**
* Get an OAuth2 provider based on configuration.
*
* @param string $providerName The provider name (google, microsoft, azure, yahoo, keycloak).
* @param array $params The configuration parameters (clientId, clientSecret, etc).
*
* @return AbstractProvider|null
*/
public static function getProvider(string $providerName, array $params): ?AbstractProvider {
switch ($providerName) {
case 'google':
return new Google($params);
case 'microsoft':
return new Microsoft($params);
case 'azure':
return new Azure($params);
case 'yahoo':
return new Yahoo($params);
case 'keycloak':
return new Keycloak($params);
default:
return null;
}
}
/**
* Get the default options for a provider.
*
* keycloak: scopes are realm-specific; caller must supply via params, not defaults.
*
* @param string $providerName The provider name.
*
* @return array
*/
public static function getDefaultOptions(string $providerName): array {
switch ($providerName) {
case 'google':
return [
'scope' => ['https://mail.google.com/']
];
case 'microsoft':
return [
'scope' => ['wl.imap', 'wl.offline_access']
];
case 'azure':
return [
'scope' => ['https://outlook.office.com/SMTP.Send', 'offline_access']
];
default:
return [];
}
}
}
+11 -29
View File
@@ -5610,39 +5610,21 @@ function mailer(array|string $from, array|string $to, null|array|string $cc = nu
return __('No OAuth2 refresh token is specified. Configure OAuth2 correctly.');
}
switch (read_config_option('settings_oauth2_provider')) {
case 'google':
$provider = new League\OAuth2\Client\Provider\Google([
'clientId' => $clientId,
'clientSecret' => $clientSecret,
]);
require_once(CACTI_PATH_LIBRARY . '/CactiOAuth.php');
break;
case 'azure':
$provider = new Greew\OAuth2\Client\Provider\Azure([
'clientId' => $clientId,
'clientSecret' => $clientSecret,
'tenantId' => $tenantId,
]);
$providerName = read_config_option('settings_oauth2_provider');
$params = [
'clientId' => $clientId,
'clientSecret' => $clientSecret,
];
break;
case 'yahoo':
$provider = new Hayageek\OAuth2\Client\Provider\Yahoo([
'clientId' => $clientId,
'clientSecret' => $clientSecret,
]);
break;
case 'microsoft':
$provider = new Stevenmaguire\OAuth2\Client\Provider\Microsoft([
'clientId' => $clientId,
'clientSecret' => $clientSecret,
]);
break;
if ($providerName == 'azure') {
$params['tenantId'] = $tenantId;
}
if ($provider !== false) {
$provider = CactiOAuth::getProvider($providerName, $params);
if ($provider !== null) {
$mail->setOAuth(
new PHPMailer\PHPMailer\OAuth([
'provider' => $provider,
+9 -38
View File
@@ -23,6 +23,7 @@
*/
require('./include/global.php');
require_once(CACTI_PATH_LIBRARY . '/CactiOAuth.php');
if (read_config_option('settings_how') != 3) {
cacti_log('WARNING: Trying get OAuth2 token but different mail method is configured');
@@ -47,46 +48,16 @@ $options = [];
$providerName = read_config_option('settings_oauth2_provider');
switch ($providerName) {
case 'google':
$provider = new League\OAuth2\Client\Provider\Google($params);
$options = [
'scope' => [
'https://mail.google.com/'
]
];
if ($providerName == 'azure') {
$params['tenantId'] = $tenantId;
}
break;
case 'yahoo':
$provider = new Hayageek\OAuth2\Client\Provider\Yahoo($params);
$provider = CactiOAuth::getProvider($providerName, $params);
$options = CactiOAuth::getDefaultOptions($providerName);
break;
case 'microsoft':
$provider = new Stevenmaguire\OAuth2\Client\Provider\Microsoft($params);
$options = [
'scope' => [
'wl.imap',
'wl.offline_access'
]
];
break;
case 'azure':
$params['tenantId'] = $tenantId;
$provider = new Greew\OAuth2\Client\Provider\Azure($params);
$options = [
'scope' => [
'https://outlook.office.com/SMTP.Send',
'offline_access'
]
];
break;
default:
cacti_log('ERROR: Unknown OAuth2 provider');
die('Provider missing');
if ($provider === null) {
cacti_log('ERROR: Unknown OAuth2 provider');
die('Provider missing');
}
if (!isrv('code')) { // If we don't have an authorization code then get one
+110
View File
@@ -0,0 +1,110 @@
<?php
require_once dirname(__DIR__) . '/Helpers/UnitStubs.php';
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/lib/CactiOAuth.php';
use League\OAuth2\Client\Provider\Google;
it('returns null for unknown provider', function () {
expect(CactiOAuth::getProvider('unknown', []))->toBeNull();
});
it('returns a Google provider correctly', function () {
$params = [
'clientId' => 'id',
'clientSecret' => 'secret',
'redirectUri' => 'uri'
];
$provider = CactiOAuth::getProvider('google', $params);
expect($provider)->toBeInstanceOf(Google::class);
});
it('returns a Keycloak provider correctly', function () {
$params = [
'clientId' => 'id',
'clientSecret' => 'secret',
'redirectUri' => 'uri',
'authServerUrl' => 'http://localhost:8080/auth',
'realm' => 'master'
];
$provider = CactiOAuth::getProvider('keycloak', $params);
expect($provider)->toBeInstanceOf(\Stevenmaguire\OAuth2\Client\Provider\Keycloak::class);
});
it('returns default options for known providers', function () {
$options = CactiOAuth::getDefaultOptions('google');
expect($options)->toHaveKey('scope');
expect($options['scope'])->toContain('https://mail.google.com/');
});
it('returns empty options for unknown providers', function () {
expect(CactiOAuth::getDefaultOptions('unknown'))->toBeEmpty();
});
it('returns Microsoft, Azure, Yahoo providers', function () {
$params = ['clientId' => 'id', 'clientSecret' => 'secret', 'redirectUri' => 'uri'];
expect(CactiOAuth::getProvider('microsoft', $params))
->toBeInstanceOf(\Stevenmaguire\OAuth2\Client\Provider\Microsoft::class);
expect(CactiOAuth::getProvider('azure', $params + ['tenantId' => 't']))
->toBeInstanceOf(\Greew\OAuth2\Client\Provider\Azure::class);
expect(CactiOAuth::getProvider('yahoo', $params))
->toBeInstanceOf(\Hayageek\OAuth2\Client\Provider\Yahoo::class);
});
it('returns expected default scopes per provider', function () {
expect(CactiOAuth::getDefaultOptions('microsoft')['scope'])->toContain('wl.imap');
expect(CactiOAuth::getDefaultOptions('azure')['scope'])->toContain('offline_access');
expect(CactiOAuth::getDefaultOptions('keycloak'))->toBe([]);
});
it('returns null when provider name has wrong case', function () {
$params = ['clientId' => 'id', 'clientSecret' => 'secret', 'redirectUri' => 'uri'];
expect(CactiOAuth::getProvider('GOOGLE', $params))->toBeNull();
});
it('returns null for an empty provider name', function () {
expect(CactiOAuth::getProvider('', []))->toBeNull();
});
it('returns the full Google scope set in defaults', function () {
expect(CactiOAuth::getDefaultOptions('google')['scope'])->toContain('https://mail.google.com/');
});
it('returns both Microsoft scope entries in defaults', function () {
$scope = CactiOAuth::getDefaultOptions('microsoft')['scope'];
expect($scope)->toContain('wl.imap');
expect($scope)->toContain('wl.offline_access');
});
it('returns both Azure scope entries in defaults', function () {
$scope = CactiOAuth::getDefaultOptions('azure')['scope'];
expect($scope)->toContain('https://outlook.office.com/SMTP.Send');
expect($scope)->toContain('offline_access');
});
it('returns empty defaults for yahoo', function () {
expect(CactiOAuth::getDefaultOptions('yahoo'))->toBe([]);
});
it('returns empty defaults for keycloak', function () {
expect(CactiOAuth::getDefaultOptions('keycloak'))->toBe([]);
});
it('returns empty defaults for an unknown provider', function () {
expect(CactiOAuth::getDefaultOptions('unknown'))->toBe([]);
});
it('returns empty defaults for an empty provider name', function () {
expect(CactiOAuth::getDefaultOptions(''))->toBe([]);
});
it('runtime OAuth entrypoints load the flat CactiOAuth helper before use', function () {
$oauth2 = file_get_contents(dirname(__DIR__, 2) . '/oauth2.php');
$functions = file_get_contents(dirname(__DIR__, 2) . '/lib/functions.php');
expect($oauth2)->toContain("require_once(CACTI_PATH_LIBRARY . '/CactiOAuth.php')")
->and($oauth2)->toContain('CactiOAuth::getProvider')
->and($functions)->toContain("require_once(CACTI_PATH_LIBRARY . '/CactiOAuth.php')")
->and($functions)->toContain('CactiOAuth::getProvider');
});