Skip to content
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

Providers for client_credentials grant flow? #1

Open
Smig0l opened this issue Sep 27, 2023 · 31 comments
Open

Providers for client_credentials grant flow? #1

Smig0l opened this issue Sep 27, 2023 · 31 comments

Comments

@Smig0l
Copy link

Smig0l commented Sep 27, 2023

it seeems that https://github.com/thephpleague/oauth2-client supports client_credentials flow but with a Genericprovider.
is there any provider for Azure that supports it?
it seems that Azure SMTP has been updated to use client_credentials after configuring a Service-principal.

@decomplexity
Copy link
Owner

The only two providers I have used are Jesper Skytte Marcussen’s greew/oauth2-azure-provider and Jan Hajek’s TheNetworg/[oauth2-azure]. Since MSFT did not, as the time, support client_credentials grant for SMTP, I couldn’t experiment to see at what point authorization failed (if indeed it did), and I recall that IMAP and POP were supported by not SMTP. I had missed the recent (10th July) this year Exchange Team’s announcement of SMTP support, so thank you for letting me know. Google had not, at the time, made any statement about future implementation, and I assume this is still the case.

Looking at TheLeague’s code, it was obvious that client_credentials grants were supported, and I remember wondering whether the typical provider class (e.g. thenetworg/Provider/Azure.php) would handle client_credentials transparently since it extended theleague/Provider/AbstractProvider.php.

The decomplexity wrapper code was, as you know, extended to handle client_credentials grant, but the PHPMailer’s OAuth2.php was written to handle authorization_code grant only. The wrapper’s setup file SendOauth2D-settings.php allowed the grant type to be set by service provider (e.g. MSFT), and the relevant parameter 'grantTypeValue' was passed to PHPMailer’s OAuth2.php

My updated PHPMailer OAuth2.php looked something like:

<?php

/**
 * PHPMailer - PHP email creation and transport class.
 * PHP Version 5.5.
 *
 * @see       https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
 *
 * @author    Marcus Bointon (Synchro/coolbru) <[email protected]>
 * @author    Jim Jagielski (jimjag) <[email protected]>
 * @author    Andy Prevost (codeworxtech) <[email protected]>
 * @author    Brent R. Matzelle (original founder)
 * @copyright 2012 - 2020 Marcus Bointon
 * @copyright 2010 - 2012 Jim Jagielski
 * @copyright 2004 - 2009 Andy Prevost
 * @license   http:https://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
 * @note      This program is distributed in the hope that it will be useful - WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.
 */

namespace PHPMailer\PHPMailer;


use League\OAuth2\Client\Grant\ClientCredentials; 
use League\OAuth2\Client\Grant\RefreshToken;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Token\AccessToken;

/**
 * OAuth - OAuth2 authentication wrapper class.
 * Uses the oauth2-client package from the League of Extraordinary Packages.
 *
 * @see     http:https://oauth2-client.thephpleague.com
 *
 * @author  Marcus Bointon (Synchro/coolbru) <[email protected]>
 */
class OAuth
{
    /**
     * An instance of the League OAuth Client Provider.
     *
     * @var AbstractProvider
     */
    protected $provider;

    /**
     * The current OAuth access token.
     *
     * @var AccessToken
     */
    protected $oauthToken;

    /**
     * The user's email address, usually used as the login ID
     * and also the from address when sending email.
     *
     * @var string
     */
    protected $oauthUserEmail = '';

    /**
     * The client secret, generated in the app definition of the service you're connecting to.
     *
     * @var string
     */
    protected $oauthClientSecret = '';

    /**
     * The client ID, generated in the app definition of the service you're connecting to.
     *
     * @var string
     */
    protected $oauthClientId = '';

    /**
     * The refresh token, used to obtain new AccessTokens.
     *
     * @var string
     */
    protected $oauthRefreshToken = '';



    /**
     * authorization_code or client_credentials grant
     * passed from SendOauth2B that, in turn, has decoded
     * the parameters file created from SendOauthD.
     * @var string
     */

   public $grantTypeValue = "";

   /**
	* scope value from SendOauthC passed from SendOauthB 
	* @var string
    */
	protected $scope="";


    /**
     * OAuth constructor.
     *
     * @param array $options Associative array containing
     *  'provider`, `userName`, `clientSecret`, `clientId` and `refreshToken` elements
     */
    public function __construct($options)
    {
        $this->provider = $options['provider'];
        $this->oauthUserEmail = $options['userName'];
        $this->oauthClientSecret = $options['clientSecret'];
	$this->oauth2ClientCertificatePrivateKey = $options['clientCertificatePrivateKey']; 
        $this->oauth2ClientCertificateThumbprint = $options['clientCertificateThumbprint']; 
        $this->oauthClientId = $options['clientId'];
        $this->oauthRefreshToken = $options['refreshToken'];
		$this->grantTypeValue = $options['grantTypeValue'];
		$this->scope = $options['scope'];
		
    }

    /**
     * Get a new RefreshToken.
     *
     * @return RefreshToken
     */
    protected function getGrant()
    {
        return new RefreshToken();
    }

    /**
     * Get a new AccessToken.
     *
     * @return AccessToken
     */
	
    protected function getToken()
	{
	switch ($this->grantTypeValue) {

	case "authorization_code":
	// use refresh token to get access token
    
	$accesstoken = $this->provider->getAccessToken(
           $this->getGrant(),
           ['refresh_token' => $this->oauthRefreshToken]
           );
	return $accesstoken;
	
/**
	return $this->provider->getAccessToken(
           $this->getGrant(),
           ['refresh_token' => $this->oauthRefreshToken]
           );
**/		   
		   
	 break;
	    
	case "client_credentials":
    $ret = $this->provider->getAccessToken(
	       $this->grantTypeValue,  
		   ['scope' => $this->scope,
		   'client_id' => $this->oauthClientId,
		   'client_secret' => $this->oauthClientSecret,
		   ]);
	return $ret;
	break;
    
	default:
    echo("ERROR - grant type value neither authorization_code nor client_credentials");
	exit;

    } //ends switch
    }
   /**
    * ends getToken method
    */
	
			
    /**
     * Generate a base64-encoded OAuth token.
     *
     * @return string
     */
    public function getOauth64()
    {
        // Get a new token if it's not available or has expired
        if (null === $this->oauthToken || $this->oauthToken->hasExpired()) {
            $this->oauthToken = $this->getToken();
        }

        return base64_encode(
            'user=' .
            $this->oauthUserEmail .
            "\001auth=Bearer " .
            $this->oauthToken .
            "\001\001"
        );
    }
}

@Smig0l
Copy link
Author

Smig0l commented Sep 28, 2023

so i tried to use client_credentials flow using theleague's provider but it errors with:
Fatal error: Uncaught League\OAuth2\Client\Provider\Exception\IdentityProviderException: invalid_request AADSTS900144: The request body must contain the following parameter: 'refresh_token'.

but if u make a request to the token endpoint to get an AccessToken it does not return a refreshToken (like tha auth code grant) see: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#use-a-token.

I don't know PHP enough to be able to make a PR in the PHPMailer repo or theleagues repo...
Also it seems that only few people tried the cliend_credentials flow.

Btw i appreciate your help and i hope u have time to get back to this and maybe update these repos.

@decomplexity
Copy link
Owner

I have tested this myself today using the decomplexity wrapper, TheNetworg provider and the modified PHPMailer OAuth2.php I copied to you.
Nothing code-crashed, but PHPMailer comes up with a 'failed to authenticate'. Analysis of the JWT access token doesn't show any obvious errors (the AUD claim was correct - the usual MSFT authentication problem, and there aren't any Scopes - the other usual problem!). A quick look at TheNetworg provider code appears to show that access token provision seems handed off entirely from TheNetworg provider to TheLeague's Abstract provider.
I will have another look over the weekend since I am fairly familiar with the common reasons for a failure to authenticate.
I will also check the EXO Online Powershell settings I used to register the service principal for the user principal mailbox.

@decomplexity
Copy link
Owner

Having now registered a fresh app specifically for client_credentials flow and registered a new service principal, I am still getting 'failed to authenticate' although the TheNetworg provider and TheLeague code didn't throw errors. The access token content also looks correct although I intend trying to see the effect of forcing an AUD claim of outlook.office.com instead of outlook.office365.com.

@Smig0l
Copy link
Author

Smig0l commented Oct 1, 2023

so i followed the docs and created a new app:
name => SMTPoauth
permission=> SMTP.SendAsApp

serviceprincipal:
$AADServicePrincipalDetails = Get-AzureADServicePrincipal -SearchString SMTPoauth

New-ServicePrincipal -AppId $AADServicePrincipalDetails.AppId -ObjectId $AADServicePrincipalDetails.ObjectId -DisplayName "EXO Serviceprincipal for AzureAD App $($AADServicePrincipalDetails.Displayname)"

$EXOServicePrincipal = Get-ServicePrincipal -Identity "EXO Serviceprincipal for AzureAD App $($AADServicePrincipalDetails.Displayname)"

Add-MailboxPermission -Identity "[email protected]" -User $EXOServicePrincipal.Identity -AccessRights FullAccess

getaccesstoken:
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d 'client_id=$appregclientid&scope=https%3A%2F%2Foutlook.office365.com%2F.default&client_secret=$appregsecret&grant_type=client_credentials' 'https://login.microsoftonline.com/$tenantid/oauth2/v2.0/token' | jq -r .access_token

Mail Sent!

i think the issue was the serviceprincipal config that i created a lot of time ago.

Now the problem is that PHPMailer expect a refresh_token but with the client_credentials flow no refresh_token is issued

@decomplexity
Copy link
Owner

decomplexity commented Oct 1, 2023

Excellent. Since without trouble I had obtained an access token that looked OK in all the usual respects, the likely culprits were either the wrong Service Principal or wrong Object ID. As I said earlier, the two usual reasons for 'failure to authenticate' are having the wrong AUD claim (an AUD of Graph is a guaranteed failure) and using the wrong scope operands or operands in the wrong order (!! - an undocumented restriction), but since scope for client_credentials flow is a single fixed URL and not a list of scopes, the fault probably lay elsewhere.
PHPMailer's standard OAuth.php expects a refresh token, hence the sample (untested...) code changes I gave earlier that present (as you did) client_id and client_secret with a grant type of client_credentials instead. The switching between authorization_code flow (with a refresh_token grant) and client_credentials flow (with client_id and client_secret and client_credentials grant) is done by adding a new OAuth.php constructor parameter grantTypeValue that, in the wrapper, is passed from SendOauth2D-settings.php but could easily be set in the global code that calls PHPMailer.

One issue with TheLeague's oauth2-client is that there is not, as far as I am aware, any mechanism to refresh a refresh token, either when one was expiring or when obtaining each new access token. The wrapper handles this but it also needs a one-line addition to TheLeague's AccessToken.php. Using client_credentials instead makes this change unnecessary!

[It is counter-intuitive that permissions for authorisation_code flow are set in Graph, but the scopes to be used must refer to outlook.office.com. If the first scope operand refers to Graph or is a valid Graph scope, (e.g. user.read), any remaining operands are also assumed to be Graph scopes unless they are prefixed with a URL. More details in my "Microsoft OAuth2 SMTP issues.md"]

@decomplexity
Copy link
Owner

decomplexity commented Oct 2, 2023

Over the weekend I created yet another app.
As before, the EXOservice principal, ADservice princpal, objectID and AppID were all generated with no problem.

I then amended PHPMailer OAuth.php to use Curl - rather as you did - to get an access token directly instead of relying on TheNetworg provider and TheLeague code:

case "client_credentials":
//    $ret = $this->provider->getAccessToken(
//	       $this->grantTypeValue,  
//		   ['scope' => $this->scope,
//		   'client_id' => $this->oauthClientId,
//		   'client_secret' => $this->oauthClientSecret,
//		   ]);

    $curl_url = 'https://login.microsoftonline.com/<my tenant ID>/oauth2/v2.0/token';
    $curl_postfields  = 'client_id=' . $this->oauthClientId;
    $curl_postfields .= '&';
    $curl_postfields .= 'scope=' . $this->scope;
    $curl_postfields .= '&';
    $curl_postfields .= 'client_secret=' . $this->oauthClientSecret; 
    $curl_postfields .= '&';
    $curl_postfields .= 'grant_type=' . $this->grantTypeValue;  
    $curl_handle = curl_init();
    curl_setopt($curl_handle, CURLOPT_URL, $curl_url);
    curl_setopt($curl_handle, CURLOPT_POSTFIELDS, $curl_postfields);
    curl_setopt($curl_handle,CURLOPT_RETURNTRANSFER,true);
    $ret = curl_exec($curl_handle);
    curl_close($curl_handle); 
    $ret = rtrim(substr($ret,strpos($ret,'"access_token":"')+16),'"}');
    echo("Access token =" . $ret);

    return $ret;
    break;

The access token thus created still caused SMTP to fail authentication!
Inspecting the access token with jwt.ms reveals nothing odd - apart from the iss claim:

https://sts.windows.net/<my tenant ID>/

that does not end /v2.0 in spite of the URL being aimed at the v2.0 endpoint.
This may be correct if outlook.office365.com (like Graph) only accepts V1 tokens as the token endpoint has to issue a token that conforms to whatever the resource API accepts. Tokens, unlike authorization_code flow authorization codes, can only refer to one resource so this isn't a problem and of course V2 endpoints can issue V1 tokens and vice versa.

@Smig0l
Copy link
Author

Smig0l commented Oct 3, 2023

The access token thus created still caused SMTP to fail authentication!

make sure no MFA policy is set on the mailbox, security defaults off.
also you should use outlook.office365 as scope and the clientId that u see on the app registration panel.

this is my accesstoken decoded from jwt.ms:

{ "typ": "JWT", "nonce": "gqyhToeoN5EXdNlG3qCDbGMXetgK0jSMF0F8rWlDXF0", "alg": "RS256", "x5t": "-KI3Q9nNR7bRofxmeZoXqbHZGew", "kid": "-KI3Q9nNR7bRofxmeZoXqbHZGew" }.{ "aud": "https://outlook.office365.com", "iss": "https://sts.windows.net/bac434ba-afc5-4650-9876-326d0147a894/", "iat": 1696163943, "nbf": 1696163943, "exp": 1696167843, "aio": "E2FgYEiqm3witoJH2UBrgpNzlu1yAA==", "app_displayname": "SMTPoauth", "appid": "a9264f05-30bb-4a2e-ba52-4a03107b0157", "appidacr": "1", "idp": "https://sts.windows.net/bac434ba-afc5-4650-9876-326d0147a894/", "oid": "33e7c3ac-028b-402c-8be8-6b2dd4e23c75", "rh": "0.AYIAujTEusWvUEaYdjJtAUeolAIAAAAAAPEPzgAAAAAAAACCAAA.", "roles": [ "SMTP.SendAsApp" ], "sid": "2487aab3-d16d-4e47-bcc5-71d702dee1e2", "sub": "33e7c3ac-028b-402c-8be8-6b2dd4e23c75", "tid": "bac434ba-afc5-4650-9876-326d0147a894", "uti": "GRiQTDkT0UCqsAaP_JEKAA", "ver": "1.0", "wids": [ "0997a1d0-0d1d-4acb-b408-d5ca73121e90" ] }

@decomplexity
Copy link
Owner

My finger trouble! I reran the Curl and it worked.
I then compared the token obtained from Curl and the token obtained from PHPMailer Oauth.php's getAccessToken (the commented block in my example earlier).
The crucial difference is in the tenant value of the iss and idp claims:

  • the Curl version contained my tenant ID
  • the getAccessToken version contained the tenantID f8cdef31-a31e-4b4a-93e4-5f571e91255a which is MSFT's tenant ID for its own Enterprise applications

And whereas the Curl token contained a role claim of [SMTP.SendAsApp], there is no role claim in the getAccessToken token, presumably because the SMTP.SendAsApp permission is set only in my tenant.

So I now need to check whether the tenantID is obtained by the TheNetWorg provider or the TheLeague's Abstract provider.

@Smig0l
Copy link
Author

Smig0l commented Oct 3, 2023

So how hard would it be to add the client_credential flow to thenetworg's provider and make PHPMailer to accept access_token only instead of a refresh_token?

@decomplexity
Copy link
Owner

I don't think PHPMailer itself would need changing outside of Oauth.php, but (from memory) TheNetworg provider default tenantID is 'common' (i.e. a multi-organisation 'tenant'). It may just need a simple override when called: I don't think $tenant is a parameter of Azure.php's constructor but will need to check.

@decomplexity
Copy link
Owner

$tenant is fortunately a public property (but not a constructor parameter) of TheNetworg provider Azure.php.
PHPMailer authentication with the client_credentials additions (i.e. the uncommented commented-out code in my example earlier, not the Curl version which we know works) now also works if $tenant is specified after instantiation of Azure.php:

$this->provider = new Azure(
                    ['clientId'     => $this->clientId,
                    'clientSecret'  => $this->clientSecret,
                    etc 
		    ] );
$this->provider->tenant = your app tenant ID;

@decomplexity
Copy link
Owner

I have upgraded the decomplexity/SendOauth2 wrapper on GitHub and Packagist to accept an optional tenant ID. This should not be needed for client_credentials grant if, by default, the provider specified a tenant ID instead of 'common' as Organisation in the URL parameters when calling the token endpoint.
It's worth noting that whereas using 'common' is acceptable for authorization_code grant (because a user is logging on), only tenant GUID and domain name are allowed for client_credentials grant.

I will put together an update to Oauth.php such that it isn't a breaking change, and offer it as a PR to @Synchro for PHPMailer

@schiorean
Copy link

Hi, I hope it's fine I update this issue because it's about client_credentials grant flow.
I was following this example file https://github.com/PHPMailer/PHPMailer/blob/master/examples/sendoauth2.phps and after a few modifications (e.g. port 587) I was able to send an email using authorization_code grant.

Since our application is a server application I tried using client_credentials grant but whatever I tried it's always giving me "AUTH command failed: 535 5.7.3 Authentication unsuccessful".

P.S. I added FullAccess rights to the mailbox via Exchange Online Powershell.

@decomplexity Any hints where I can have a look for a complete example how I can use client_credentials grant with PHPMailer and SendOauth2?

Thank you in advance!

@Smig0l
Copy link
Author

Smig0l commented Mar 25, 2024

Hi, I hope it's fine I update this issue because it's about client_credentials grant flow. I was following this example file https://github.com/PHPMailer/PHPMailer/blob/master/examples/sendoauth2.phps and after a few modifications (e.g. port 587) I was able to send an email using authorization_code grant.

Since our application is a server application I tried using client_credentials grant but whatever I tried it's always giving me "AUTH command failed: 535 5.7.3 Authentication unsuccessful".

P.S. I added FullAccess rights to the mailbox via Exchange Online Powershell.

@decomplexity Any hints where I can have a look for a complete example how I can use client_credentials grant with PHPMailer and SendOauth2?

Thank you in advance!

Hi! since i was in the same situation as u basically i solved it without using this library.
I tested it using phpmailer 6.8.1 in my wordpress functions.php

/*=====================================
 Inizio OAUTH PHPMAILER
 (Imposto l'invio di email in smtp usando OAUTH e 365)
 SEE DOCS: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#use-client-credentials-grant-flow-to-authenticate-smtp-imap-and-pop-connections
 REQUIREMENTS:
 - composer require phpmailer/phpmailer
 - a registered app created in 365 with SMTP.sendAsApp API_permission and a ServicePrincipal with fullAccess on the mailbox that needs to send from(see docs)
=====================================*/

require get_template_directory() . '/vendor/autoload.php'; //ensure that we load the vendor folder under the theme directory

use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\OAuthTokenProvider;

class OAuth implements OAuthTokenProvider
{
	//Basically created a custom OAuth class that generate and accept an oauthAccessToken in the constructor (instead of a refreshToken).
	//Made it because as of Oct 23 there aren't any providers that implemented a client_credentials flow and the included OAuth class was not compatible.

	protected $scope = 'https://outlook.office365.com/.default';
	protected $grantTypeValue= 'client_credentials';

	/**
	 * The current OAuth access token.
	 *
	 * @var string
	 */
	protected $oauthAccessToken;

	/**
	 * The user's email address, usually used as the login ID
	 * and also the from address when sending email.
	 *
	 * @var string
	 */
	protected $oauthUserEmail = '';

	/**
	 * The client secret, generated in the app definition of the service you're connecting to.
	 *
	 * @var string
	 */
	protected $oauthClientSecret = '';

	/**
	 * The client ID, generated in the app definition of the service you're connecting to.
	 *
	 * @var string
	 */
	protected $oauthClientId = '';

	/**
	 * The tenant ID, visible in the app definition of the service you're connecting to.
	 *
	 * @var string
	 */
	protected $oauthTenantId = '';

	/**
	 * OAuth constructor.
	 *
	 * @param array $options Associative array containing
	 *                       `userName`, `tenantId`, `clientId`, `clientSecret` and optionally an `accessToken` elements
	 */
	public function __construct($options)
	{
		$this->oauthUserEmail = $options['userName'];
		$this->oauthTenantId = $options['tenantId'];
		$this->oauthClientId = $options['clientId'];
		$this->oauthClientSecret = $options['clientSecret'];
		$this->oauthAccessToken = $options['accessToken'];
	}

	/**
	 * Get a new AccessToken.
	 *
	 * @return string
	 */
	protected function getAccessToken()
	{
		
		$curl_url = 'https://login.microsoftonline.com/'.$this->oauthTenantId.'/oauth2/v2.0/token';
		$curl_postfields  = 'client_id=' . $this->oauthClientId;
		$curl_postfields .= '&';
		$curl_postfields .= 'scope=' . $this->scope;
		$curl_postfields .= '&';
		$curl_postfields .= 'client_secret=' . $this->oauthClientSecret; 
		$curl_postfields .= '&';
		$curl_postfields .= 'grant_type=' . $this->grantTypeValue;  
		$curl_handle = curl_init();
		curl_setopt($curl_handle, CURLOPT_URL, $curl_url);
		curl_setopt($curl_handle, CURLOPT_POSTFIELDS, $curl_postfields);
		curl_setopt($curl_handle,CURLOPT_RETURNTRANSFER,true);
		$result = curl_exec($curl_handle);
		curl_close($curl_handle);
		$accessToken = rtrim(substr($result, strpos($result, '"access_token":"') + 16), '"}');
		return $this->oauthAccessToken = $accessToken;
	}

	/**
	 * Generate a base64-encoded OAuth token.
	 *
	 * @return string
	 */
	public function getOauth64()
	{
		if (null === $this->oauthAccessToken) {
			$this->oauthAccessToken = $this->getAccessToken();
		}
		return base64_encode(
			'user=' .
			$this->oauthUserEmail .
			"\001auth=Bearer " .
			$this->oauthAccessToken .
			"\001\001"
		);
	}
}

function send_mail($to, $subject, $body) {

	date_default_timezone_set('Etc/UTC'); //SMTP needs accurate times, and the PHP time zone MUST be set
	$mail = new PHPMailer(true); //create a new instance of the PHPMailer class and set it to throw exceptions if there are any errors
	$mail->isSMTP();
	$mail->SMTPDebug = SMTP::DEBUG_OFF; //Enable SMTP debugging
	$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; //Set the encryption mechanism to use (SMTPS -> implicit TLS on port 465 or STARTTLS -> explicit TLS on port 587)
	$mail->SMTPAuth = true; //Whether to use SMTP authentication
	$mail->Host = 'smtp.office365.com'; //Set the hostname of the mail server
	$mail->Port = 587; //Set the SMTP port number (587 or 465 or 25)
	$mail->AuthType = 'XOAUTH2'; //Set AuthType to use XOAUTH2
	$mail->XMailer = ' '; //remove X-Mailer: PHPMailer header

	$mail->Subject = $subject;
    $mail->Body = $body;
	$mail->IsHTML(true);
	$mail->addAddress($to);

	$tenantId = 'YOURTENANTID';
	$clientId = 'YOURAPPID';
	$clientSecret = 'YOURSECRET';
	$from = '[email protected]';
	$mail->setFrom($from, 'NoReply example');

	$debug = false;
	if($debug){
		error_reporting(E_ALL);
		ini_set('display_errors', 1);
		$mail->SMTPDebug = SMTP::DEBUG_SERVER;
		$tenantId = 'YOURTENANTID';
	  $clientId = 'YOURAPPID';
	  $clientSecret = 'YOURSECRET';
	  $from = '[email protected]';
	  $mail->setFrom($from, 'NoReply example');
    }

	$mail->setOAuth(
		new OAuth(
			[
				'userName' => $from,
				'tenantId' => $tenantId,
				'clientId' => $clientId,
				'clientSecret' => $clientSecret,
			]
		)
	);

	if (!$mail->send()) { //send the message, check for errors
		echo 'Mailer Error: ' . $mail->ErrorInfo;
	} else {
		//echo 'message sent!';
	}
	
}

/*=====================================
 Fine OAUTH PHPMAILER
 
=====================================*/

so u just use send_mail() everywhere u need.

Hope it helps!

P.S. IIRC the clientid refers to the objectid of the created serviceprincipal.

@decomplexity
Copy link
Owner

Firstly, Mea culpa. I regret changing the port from 587 to 465 (implicit TLS): I have myself always used 587, but when I submitted the example as a PR to PHPMailer in October '23 , @Synchro suggested using 465 "... per RFC8314:

$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = 465; "

My fault forgetting that MSFT does not like 465.

Secondly, a couple of days ago, we released V4.0.0 on the decomplexity/sendoauth2 repo. This release supports Google's official 'Client' API for both authorization_code and client_credentials grant, and for both client_secret and X.509 certificates; but the support for MSFT is essentially unchanged, and I don't think there even any bug fixes in that area. The 'global code' example that will be updated on the PHPMailer repo is awaiting review and approval by @Synchro, but we will put a copy on the decomplexity/sendoauth2 repo as well - tonight or tomorrow.

There is no additional worked example since the only additional setup is detailed in MSFT's document on the Exchange Team blog "Announcing Client Credential flow for SMTP AUTH in Exchange Online" which you will have read. When you registered your app for authorization_code grant, you implicitly registered a user principal (or delegated access). Since client_credentials grant has no user, the app has instead to be registered as a service principal, and the last couple of pages of blog pages details this. And they highlight the trap of using the wrong OBJECT_ID - using the one from the app Registration page rather than the correct one from the Enterprise Application page (which is essentially just a list of principals). I remember one oddity in MSFT's example of service principal registration: in the line $AADServicePrincipalDetails = ...., , YourAppName must be in quotes.

Since you are getting an authentication failure, and since module SendOauth2C handles scope (permissions) and any overrides needed, it may be helpful to turn on PHPMailer SMTP DEBUG diagnostics and check (or send me) the access code.

@Synchro
Copy link

Synchro commented Mar 25, 2024

That's very weird about port 465! SMTPS on 465 was originally deprecated only a few months after it was introduced in favour of STARTTLS on 587, however more or less the only implementer that didn't make this switch was Microsoft who never stopped using it for SMTP on Exchange, Outlook, Live, Hotmail, O365, and whatever they are calling it this week. It all came full circle in RFC8314 when the roles were reversed and SMTPS on 465 was effectively undeprecated. Are you saying that just when everyone is switching back to the port that MS never stopped using, they suddenly decided to stop using it??

@decomplexity
Copy link
Owner

I can’t keep track of MSFT’s changes; I assume there is some logic to them.
All I know is that I used ENCRYPTION_SMTPS on port 465 last week and to my surprise MSFT bounced it. So I reverted to ENCRYPTION_STARTTLS on 587 and MSFT was happy. Whether this was a temporary MSFT glitch or a change of direction I don’t know.

@schiorean
Copy link

Thank you everyone for the help! I succeeded making it work, the code was fine the problem was the way I registered the service principal.

Not in a million years could have fixed it, so thank you very much @decomplexity for taking the time and mention this quirk.

And they highlight the trap of using the wrong OBJECT_ID - using the one from the app Registration page rather than the correct one from the Enterprise Application page (which is essentially just a list of principals). I remember one oddity in MSFT's example of service principal registration: in the line $AADServicePrincipalDetails = ...., , YourAppName must be in quotes.

@eradin
Copy link

eradin commented Jun 14, 2024

I have had both MS (client_credentials) and Gmail (authorization_code) working for some time. I am interested in implementing client_credentials for gmail. I updated the library to V4, configured a service account and Domain-wide delegation with google. When I run the code I get the following exception:

[14-Jun-2024 00:47:21 UTC] PHP Warning: file_put_contents(gmail-xoauth2-credentials.json): Failed to open stream: Permission denied in /home/mwb/public_html/api/lib/vendor/decomplexity/sendoauth2/src/SendOauth2ETrait.php on line 185
[14-Jun-2024 00:47:21 UTC] PHP Fatal error: Uncaught GuzzleHttp\Exception\ClientException: Client error: POST https://oauth2.googleapis.com/token resulted in a 401 Unauthorized response:
{
"error": "invalid_client",
"error_description": "The OAuth client was not found."
}
in /home/mwb/public_html/api/lib/vendor/guzzlehttp/guzzle/src/Exception/RequestException.php:113

It looks like a json file is being written out and failing due to lack of permissions. The vendor directory is read only. Are you sure this code works in a linux environment? Perhaps I missed a config setting somewhere.

Thanks for your time.

@decomplexity
Copy link
Owner

Hi @eradin

SendOauth2 was developed and runs on Linux.

Subdirectory vendor and its children decomplexity/sendoauth2/src should indeed be read-only. But the parent of vendor - often just ‘php’ – is intended to be read-write as it will contain (if you are using the ‘full’ version of SendOauth2) not just the Google API .json but also the settings interchange file Oauth2parms_n.txt (where ‘n’ is the settings ‘case’ value), SendOauth2D_invoke.php (that calls the settings file SendOauth2D.php and uses ‘n’) and SendOauth2D.php itself (which is used to specify OAuth2 parameters).

If you wish to put the .json somewhere else read/write, especially if you were using the multi-parameter calling sequence in the example shown in the PHPMailer repo rather than the ‘full’ version of SendOauth2, you could amend the SendOauth2ETrait $GMAIL_XOUTH2_CREDENTIALS with a suitable subdirectory prefix.

If you wish to use the Google-generated .json that you have manually copied rather than the one generated by SendOauth2ETrait.php, simply set $WRITE_GMAIL_CREDENTIALS_FILE in SendOauth2ETrait to false.

(Note that because consts were not supported in Traits by php until V8.2.0, the two $s above were defined as properties).

@eradin
Copy link

eradin commented Jun 14, 2024

Thanks. To be clear, I am using the phpmailer example as my base (SendOauth2B). Another issue is the current implementation isn't thread safe as the current generated file has a fixed name. What I would like to see is a parameter where we could specify our own .json file. My application supports "connectors" where many users who can send email via their MS or google account (each specifying their own credentials) When an email goes out, I would generate a user specific json file in the tmp directory and provide SendOauth2B with that file reference. I think this is cleaner and also cuts down the number of parameters since they are contained in the json file. It's also thread safe and doesn't require specific vendor/ location and permissions.

I'm happy to create a pull request and you can integrate it into your implementation. I prefer not maintaining forks.

Another thought is to have Trait write the json to a tmp/ file but 2C would need to know the file path. Currently these are hard coded.

UPDATE: I made a couple of simple changes to write json out to the system tmp directory. It currently writes to the current directory which in my case is restricted. This also solves the thread safe issue as well. When I have a little time, I will create a pull request.

@decomplexity
Copy link
Owner

I am currently travelling and don’t have access to a test rig. Your point about being thread-safe is interesting in that we piloted a version that created the .json with a six-digit random-number filename prefix (that then necessitated housekeeping for aborted sessions), but chose to release the simpler ‘single file’ version as it suits the environment many use it for: mail-out from a website’s Contact form, PayPal IPN and PDT notifications, order confirmations and so on, where the sender is the same on each instantiation. I blame Google for using file-based credentials!

What follows gives the caller from PHPMailer an option to specify their own .json and/or use either a generated file or Google’s default. The defaults will remain if not overridden.

Note that changes are needed to the ‘full’ system in addition to the section in SendOauth2B that handles calls direct from PHPMailer. This is because SendOauth2C is invoked in both cases; changes are thus also needed to the full system’s interchange file and to SendOauth2D.

What follows is not tested and a bit rough; you may wish to compare it with your proposed PR. The code lines are unlikely to conform to PSR-12…

  1. amend the example in the PHPMailer repo:

after

$oauthTokenProvider = new SendOauth2B(…
thru
'impersonate'                 => '[email protected]',      // Google API service account only. Else null
                                                          // (Google Wspace email adddress, not @gmail) 

add:

'gmailXoauth2Credentials' => 'your credentials.json file name',  // defaults to:
                                                                //gmail-xoauth2-credentials.json
'writeGmailCredentialsFile' => 'true or false',                 //defaults to true; meaning the
                                                                // credentials.json is dynamically created
  1. amend SendOauth2B:

after (at 149 or thereabouts)

protected $impersonate = "";

add:

protected $gmailXoauth2Credentials = "gmail-xoauth2-credentials.json";
protected $writeGmailCredentialsFile = "true";

after (at 328 or thereabouts)

$this->checkParm('impersonate', $optionsB, $this->impersonate);

add:

$this->checkParm('gmailXoauth2Credentials', $optionsB, $this-> gmailXoauth2Credentials);
$this->checkParm('writeGmailCredentialsFile', $optionsB, $this-> writeGmailCredentialsFile);

after 409 or thereabouts:

            $this->impersonate,

add:

            $this-> gmailXoauth2Credentials,
            $this-> writeGmailCredentialsFile,

after 436 or thereabouts (comments):

                          'impersonate' =>  $this->impersonate,

add:

                          'gmailXoauth2Credentials' =>  $this-> gmailXoauth2Credentials,
                          'writeGmailCredentialsFile' =>  $this-> writeGmailCredentialsFile,

after (at 587 or thereabouts)

                $this->impersonate,

add:

                $this-> gmailXoauth2Credentials,
                $this-> writeGmailCredentialsFile,

after (at 651 or thereabouts)

  'impersonate' =>  $this->impersonate,

add:

'gmailXoauth2Credentials' => $this-> gmailXoauth2Credentials,
'writeGmailCredentialsFile' => $this-> writeGmailCredentialsFile,
  1. amend SendOauth2C:

at 47 or thereabouts
replace:

    const GMAIL_XOUTH2_CREDENTIALS = 'gmail-xoauth2-credentials.json';

by:

    protected $gmailXoauth2Credentials;

and add:

     protected $writeGmailCredentialsFile;  // for future use

after (180 or thereabouts)

        $this->hostedDomain = $optionsC['hostedDomain'];

add:

        $this->serviceAccountName = $optionsC['serviceAccountName']; // for future use
        $this-> projectID = $optionsC['projectID']; // for future use

after (181 or thereabouts)

        $this->impersonate = $optionsC['impersonate'];

add:

        $this-> gmailXoauth2Credentials = $optionsC['gmailXoauth2Credentials'];

at 346 or thereabouts
replace:

                $this->provider -> setAuthConfig(self::GMAIL_XOUTH2_CREDENTIALS);

by:

                $this->provider -> setAuthConfig($this-> gmailXoauth2Credentials);
  1. amend SendOauth2ETrait:

at 63 or thereabouts:
replace:

private $GMAIL_XOUTH2_CREDENTIALS = 'gmail-xoauth2-credentials.json';

by:

// protected $gmailXoauth2Credentials;     inherited from SendOauth2B

at 70 or thereabouts:
replace:

private $WRITE_GMAIL_CREDENTIALS_FILE = true;

by:

// protected $writeGmailCredentialsFile;    inherited from SendOauth2B

at 97 or thereabouts:
replace:

      $this-> WRITE_GMAIL_CREDENTIALS_FILE) {

by

       $this-> writeGmailCredentialsFile) {

at 185 or thereabouts:
replace:

            $this->GMAIL_XOUTH2_CREDENTIALS,

by:

             $this-> gmailXoauth2Credentials,
  1. amend SendOauth2D:

from 181 or thereabouts:
after:

    protected $impersonate = "";

add:

    /**
     * for GoogleAPI service accounts: the file name of the .json to be used  
     * for authentication. This will default to a string value set by SendOauth2B 
     */
      protected $gmailXoauth2Credentials;

/**
     * for GoogleAPI service accounts: a true/false flag to indicate  
     * which .json file is used: the default one generated by SendOauth2ETrait
     * or one supplied by the user, either a copy of Google’s (from console)
     * or a user-modified one. Default is set in SendOauth2B (normally ‘true’).
   */
      protected $writeGmailCredentialsFile;

after 271 or thereabouts:

            $this->checkParm('impersonate', $optionsD);

add:

            $this->checkParm('gmailXoauth2Credentials', $optionsD);
            $this->checkParm('writeGmailCredentialsFile', $optionsD);

after 508 or therebouts:

        $optionsD['impersonate'],

add:

        $optionsD['gmailXoauth2Credentials'],
        $optionsD['writeGmailCredentialsFile'],
  1. amend SendOauth2D-settings:

after 154 or thereabouts:

    'impersonate'                 =>  '[email protected]',   // client_creds with Google WSpace accts only

add:

    'gmailXoauth2Credentials'      =>  '.json file name' // client_creds with Google WSpace accts
                                                        // only. Default is set in SendOauth2B
    'writeGmailCredentialsFile'   =>  true or false     // client_creds with Google WSpace accts only.
                                                        // Default (normally true) is set in 
                                                        // SendOauth2B. True implies that the .json
                                                        // generated by SendOauth2ETrait is used.
                                                        // False that an external file is to be used

@eradin
Copy link

eradin commented Jun 15, 2024

Nice. This is the solution that's needed. My /tmp solution noted earlier was only a quickie so I could do some testing but wasn't intended to be the final solution. A couple of observations from above:

  1. Is the writeGmailCredentialsFile option even necessary? If a json file is specified, that flag should always be false. Otherwise true (default).
  2. After doing a lot of testing, the impersonate option is perplexing. I wasn't able to get the solution working unless mailSMTPAddress and impersonate were always the same and they had to be the google workplace admin account. I tried other combinations and it always failed with an error. I even tried adding alternate emails to the admin account. If this is indeed true (need to be the same), maybe eliminate the option and always set it internally to the passed in mailSMTPAddress.
  3. I agree the temp directory could have remnants not deleted from aborted pages but I noticed that the more recent versions of systemd have a setting called PrivateTmp that is defaulted to true (my Ubuntu servers all have this enabled). It apparently creates a /tmp folder for each process and cleans up automatically when the process ends. Although I added a deconstructor to 2C to unlink the file, it wasn't necessary. Here is the code I used to create a temp file:

$this->gmail_xouth2_credentials_file = tempnam(sys_get_temp_dir(), 'gmail-credentials-');

  1. I noticed you used scope Gmail::MAIL_GOOGLE_COM. This is very general and perhaps using the more restrictive Gmail::MAIL_SEND would be more appropriate (and safer). I guess you could just add both that way the user could indicate either.

I'm going to play with your code above and hold off on the PR. Looking forward to an official update.

Really appreciate this project. Oauth2 ain't easy.

@decomplexity
Copy link
Owner

In reply to your points:

  1. The writeGmailCredentialsFile option was included to allow developers to elect to use the downloaded Google .json rather than the dynamically created one (that may have same Google ‘standard’ name). In other words, it gives a choice between specifying a new name and/or location (which is what you wish to do) and using the ‘standard’ name but with a different method of creation - if you see what mean.

  2. Re ‘impersonate’, I found exactly the same odd-ball behaviour as you did. I also went through trying the various permutations with puzzlement: it is in some loose way akin to saying that the Gmail account holder must be the owning Google Workspace account holder (in MSFT terms: the tenant admin user-principal and not just an email account within that tenant). As I commented in the property declarations at the head of SendOauth2B, it is “The email address of the user for the service account to impersonate. If in doubt, use the $mailSMTPAddress value here and in Google Admin console registration”.
    In spite of testing, I still thought I was wrong and was missing something obvious, so left $mailSMTPAddress as settable in both PHPMailer’s call to SendOauth2B and in the ‘full’ implementation.
    I would prefer to leave it settable, but will specify the changes needed to allow it to default to $mailSMTPAddress – straightforward in the PHPMailer call implementation but not in the ‘full’ version as there is provision in the latter for the default envelope address to be overridden.

  3. Scope MAIL_GOOGLE_COM was specified because the more obvious and safer MAIL_SEND simply didn’t work! Whether this was an API bug at the time I don’t know, but the issue has been mentioned by others on Stackoverflow.

Finally, kindly note that there are at least 14 different combinations of provider (MSFT, TheLeague's Google provider + client, Google API), grant flow (authorization_code and client_credentials), credential (client_secret, cl;ient_certificate), and caller (PHPMailer or ‘full’) to test; there would be umpteen more but some are not supported by the provider, e.g. Google API appeared not to support authorization_code flow with certificates.

To your comment ‘Oauth2 ain't easy’, Google API was a doddle compared with MSFT’s MSAL with SMTP. My document “Microsoft OAuth2 SMTP issues” in the PHPMailer Wiki says it all, but it needed a MSFT email round trip (India, Redmond etc etc and finally Portugal) to find someone who properly understood what was going on.

@eradin
Copy link

eradin commented Jun 16, 2024

Thanks for the comments. BTW, I implemented your code changes and it works well. I had 2 small issues that needed correcting.

  1. In 2B

protected $writeGmailCredentialsFile = "true";

should be

protected $writeGmailCredentialsFile = true;

  1. In Trait

An additional reference to WRITE_GMAIL_CREDENTIALS_FILE should be changed to writeGmailCredentialsFile

@decomplexity
Copy link
Owner

Thanks for these corrections - both silly errors.
When I get back I will resuscitate the test pack but this won't be for at least two weeks.
But I might find time to specify the changes needed to specify the default for $impersonate as $mailSMTPAddress

@decomplexity
Copy link
Owner

Rough untested mods needed to allow ‘impersonate’ to default to ‘mailSMTPAddress’.
I hope against hope that I haven’t trodden on my own toes with a second sets of mods to the same base code! - very bad practice...
Line numbers refer to the current GitHub published code.

amend SendOauth2B:
after 438 or thereabouts (comments)

            'grantType' => $this->grantType,

add:

            'mailSMTPAddress' => $this-> mailSMTPAddress,

at 445 or thereabouts [corrects a Comment error]
replace

        }  // ends number of args <= 3

by

        }  // ends number of args <= NUMPARMS

at 466 or thereabouts et seq:

    /**
     * mailSMTPAddress should normally NOT be set in global but allowed to take the SendOauth2 default,
     * unless pro tem you want to override the SendOauth2 default
     * Note that neither MSFT nor Google will will allow sending from 'arbitrary' addresses
     * NB: PHPMailer mail->Username is an SMTP ADDRESS
     */

        if (empty($this->mailSMTPAddress)) {
            $this->mailSMTPAddress = $this->SMTPAddressDefault;
        }

        $this->mail->Username = $this->mailSMTPAddress; // SMTP sender email address (MSFT or Google email account)

move all this to 454 - currently blank; immediately after $this->GoogleAPIOauth2File();

after 653 or thereabouts

            'grantType' => $this->grantType,

add:

            'mailSMTPAddress' => $this-> mailSMTPAddress,

amend SendOauth2C:

after 149 or thereabouts

    protected $grantType = "";

add:

   protected $mailSMTPAddress;

after 183 or thereabouts:

        $this->grantType = $optionsC['grantType'];

add:

        $this->mailSMTPAddress = $optionsC['mailSMTPAddress'];

at 354 or thereabouts
replace:

               if ($this->impersonate != "" && $this->grantType == self::CLIENTCRED) {
                    $this->provider -> setSubject($this->impersonate); // service accounts
                                                                                                       // with domain-wide delegation
                }

by:

if ($this->grantType == self::CLIENTCRED) {
    if ($this->impersonate == "") { 
        $this->impersonate = $this->mailSMTPAddress;
        }
     $this->provider -> setSubject($this->impersonate);  // service accounts with
                                                                                         // domain-wide delegation
}

amend SendOauth2D:
at 274 or thereabouts
replace the blank line by:

$optionsD['mailSMTPAddress'] = "";  // SendOauth2C will check the key exists

@decomplexity
Copy link
Owner

@eradin - I plan next week (w.c. 1st July) to aggregate the various changes above into a subversion for testing.
If you have any view about the immediately preceding change to allow ‘impersonate’ to default to ‘mailSMTPAddress’ kindly let me know.

@eradin
Copy link

eradin commented Jun 25, 2024

Nope. I think this is a welcome addition. Look forward to integrating your official changes.

@decomplexity
Copy link
Owner

decomplexity commented Jul 4, 2024

Master now updated as promised.
One obvious change from the set of draft changes I listed above were the values of operand writeGmailCredentialsFile. In the draft these were Boolean; in the new V4.1.0 it is a string 'yes' or 'no'. This is to avoid the situation where specifying a 'false' value could be also interpreted as "" or not set, and this may upset the logic where a "" or not set could be interpreted as the default - which is supposed to be 'true' (the default for writeGmailCredentialsFile is 'true' and the Trait then generates the .json).

In practice, having the two operands writeGmailCredentialsFile and gmailXoauth2Credentials gives more flexibility to the developer than I had realised at the outset, and I have changed the logic in the Trait so as to allow the dynamically-created .json also to use the filename set in gmailXoauth2Credentials. This would allow the developer to use a dynamically-created .json with their own filenames (perhaps with a random-number prefix or user prefix to pre-empt a thread clash).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants