Skip to content

[Mailer] Improve oauth setup #52585

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

Open
wants to merge 2 commits into
base: 7.4
Choose a base branch
from
Open

[Mailer] Improve oauth setup #52585

wants to merge 2 commits into from

Conversation

dbu
Copy link
Contributor

@dbu dbu commented Nov 14, 2023

Q A
Branch? 7.1
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

Setting up the Symfony Mailer with XOAuth based authentication is possible but quite complicated. I hacked something together in https://gist.github.com/dbu/3094d7569aebfc94788b164bd7e59acc but with a few changes, I would only need to implement the token provider and configure it.

I guess this is too late for 7.0, so it should go to 7.1. I will adjust the target branch once 7.1 is created.

@carsonbot
Copy link

Hey!

Thanks for your PR. You are targeting branch "7.0" but it seems your PR description refers to branch "7.1".
Could you update the PR description or change target branch? This helps core maintainers a lot.

Cheers!

Carsonbot

foreach ($config['smtp']['authenticators'] ?? [] as $authenticator) {
$authenticators[] = new Reference($authenticator);
}
$container->getDefinition('mailer.transport_factory.smtp')->setArgument(3, $authenticators);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

according to mailer_transport.php this service is the EsmtpTransportFactory, but the CI tells that we somehow end up setting an argument to "Symfony\Component\Mailer\Mailer" - any idea what i am messing up here?

Copy link
Member

Choose a reason for hiding this comment

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

the weird error message could be because of inlining the transport factory services. It might be inside one of the inlined services inside the mailer service.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

is that a bug of the DI container, or should i be doing something differently?

Copy link
Member

Choose a reason for hiding this comment

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

I would not call it a bug. The hard thing is that inline definitions don't have an id. So that's hard to know how to mention them in error messages.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if it is not a bug, what do i need differently to set the parameter on the correct place?

@nicolas-grekas nicolas-grekas modified the milestones: 7.0, 7.1 Nov 17, 2023
@carsonbot carsonbot changed the title improve oauth setup for mailer [Mailer] improve oauth setup for mailer Nov 17, 2023
@nicolas-grekas nicolas-grekas changed the title [Mailer] improve oauth setup for mailer [Mailer] Improve oauth setup Nov 17, 2023
@nicolas-grekas nicolas-grekas changed the base branch from 7.0 to 7.1 November 17, 2023 15:56
@nicolas-grekas
Copy link
Member

Did you consider adding the oauth token providen to core? Could this be done instead of the current extra interface? Is there an approach that wouldn't require adding this extra interface - or at least making XOAuth2Authenticator unaware of it? I don't really like the instanceof check there 😬

@dbu
Copy link
Contributor Author

dbu commented Nov 20, 2023

Did you consider adding the oauth token providen to core? Could this be done instead of the current extra interface? Is there an approach that wouldn't require adding this extra interface - or at least making XOAuth2Authenticator unaware of it?

oauth seems to be more of a concept than an actual standard. every provider is doing it differently. if symfony had a fully fledged oauth component with support for all the major players, we could maybe make this use that. but to me an interface is really the way to go here.

oauth means sending a post with some json body to some url. but which field names to use, whats required or optional and what values to use e.g. for the scope is totally provider dependent.

for office365/exchange, the values happen to be

    private const OAUTH_URL = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token';
    private const SCOPE = 'https://outlook.office365.com/.default';
    private const GRANT_TYPE = 'client_credentials';

        $data = [
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret,
            'scope' => self::SCOPE,
            'grant_type' => self::GRANT_TYPE,
        ];

    // post that as x-www-form-urlencoded to
    UriTemplate::expand(self::OAUTH_URL, [
            'tenant' => $this->tenant,
    ]));

I don't really like the instanceof check there 😬

i think the null check is required for BC.

@nicolas-grekas
Copy link
Member

Sorry I wrote "instanceof" but that's inaccurate. I meant this line:
https://github.com/symfony/symfony/pull/52585/files#diff-3ac0365eb0e52c96210c4fc1848acd4be3eb71ceb86aed561b76b8d1d3626b6dR42

@@ -32,6 +39,7 @@ public function getAuthKeyword(): string
*/
public function authenticate(EsmtpTransport $client): void
{
$client->executeCommand('AUTH XOAUTH2 '.base64_encode('user='.$client->getUsername()."\1auth=Bearer ".$client->getPassword()."\1\1")."\r\n", [235]);
$token = $this->tokenProvider ? $this->tokenProvider->getToken() : $client->getPassword();
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
$token = $this->tokenProvider ? $this->tokenProvider->getToken() : $client->getPassword();
$token = $this->tokenProvider?->getToken() : $client->getPassword();

But I'm wondering about this line as now we give two ways to pass that secret - what if both are set for example? I feel like it'd be better to provide only one way to pass that secret - the existing one if possible :) Does that make sense? (it doesn't have to :) )

Copy link
Member

Choose a reason for hiding this comment

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

the issue with the existing one is that oauth tokens tend to be short-lived. In your worker process, you cannot just set the token as the password in the Transport as this will not properly refresh it when it expires.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changing the client to fetch the password dynamically would be a large refactor, and would mix responsibilities for the auth method.

the thing is that an oauth token can not be statically configured. and in a long running process it might expire before the process ends, so each time we need it we need to be ready to re-fetch it.

we could inject some TokenProviderInterface into the client and use that for getPassword(), but i don't think that would be better.

…ticator.php

Co-authored-by: Nicolas Grekas <nicolas.grekas@gmail.com>
Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

Got it for the geToken??getPassword logic, makes sense to me now.
Here are some suggestions to help move forward.

@@ -2091,6 +2091,15 @@ private function addMailerSection(ArrayNodeDefinition $rootNode, callable $enabl
->end()
->end()
->end()
->arrayNode('smtp')
->fixXmlConfig('authenticator')
Copy link
Member

Choose a reason for hiding this comment

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

instead of adding a new config entry, I'd suggest passing a tagged_locator in the service definition
we should define the default authenticators as services with low priority so that then adding a new authenticator is just a matter of creating a new class that implements AuthenticatorInterface, thanks to registerForAutoconfiguration.

* Usually with OAuth, you will need to do some web request to fetch the token.
* You also want to cache the token for as long as it is valid.
*/
interface AuthTokenProviderInterface
Copy link
Member

Choose a reason for hiding this comment

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

TokenProviderInterface?

/**
* @param AuthenticatorInterface[]|null $authenticators
*/
public function __construct(EventDispatcherInterface $dispatcher = null, HttpClientInterface $client = null, LoggerInterface $logger = null, array $authenticators = null)
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure we need the new argument: calling setAuthenticators on the resulting instance should be enough

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Nov 30, 2023

Note that it'd be cool to contribute the office365 bridge while at it :)

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Nov 30, 2023

Oh and maybe also an AbstractOauthTokenProvider?

@dbu
Copy link
Contributor Author

dbu commented Dec 13, 2023

thanks for the feedback. yeah that sounds good. i will try to find time, might be after christmas only 🙈

the office365 provider would go into a new Office365 namespace at https://github.com/symfony/symfony/tree/7.1/src/Symfony/Component/Mailer/Bridge and need a composer.json for the subtree split, right?

i will see if an abstract base class makes sense that could be shared in the messenger package.

@nicolas-grekas
Copy link
Member

the office365 provider would go into a new Office365 namespace

yes!

@pounard
Copy link
Contributor

pounard commented Feb 15, 2024

Hello, @dbu @nicolas-grekas, I stumbled here while doing some research, because I am since a few days working a new component proposal, that does exactly the job of the introduced here AuthTokenProvider, and more. Would you be interested in participating instead of adding this new interface in this PR ?

@dbu
Copy link
Contributor Author

dbu commented Feb 15, 2024

i do not have much time at the moment, so more than glad if you put some brain energy into proposing a nice architecture for the auth token system. i can try to give my input on the proposal when you have a draft ready, and will be happy to rebase this PR when/if your proposal gets merged.

@pounard
Copy link
Contributor

pounard commented Feb 16, 2024

@dbu thanks, I will in few days I hope, open an issue for it.

@pounard
Copy link
Contributor

pounard commented Feb 20, 2024

@dbu if you have a few minutes to spare, I'd very much like to have your sentiment about #54013, thanks in advance !

@pounard
Copy link
Contributor

pounard commented Feb 20, 2024

Did you consider adding the oauth token providen to core?

@nicolas-grekas I did ! Please see my PR #54013 - your insights would be invaluable !

@xabbuh xabbuh modified the milestones: 7.1, 7.2 May 15, 2024
@fabpot fabpot modified the milestones: 7.2, 7.3 Nov 20, 2024
@fabpot fabpot modified the milestones: 7.3, 7.4 May 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants