Files
projectsend/app/Modules/Platform/Mail/OAuthCodeFlowBroker.php
T
ignacionelson 81bb136e9e Merge pull request #1750 from denkfabrik-li/fix/mail-oauth-alarm-fires-once
RefreshMailOAuthTokensCommand is the daily refresh and, by its own docblock, the health check that goes with it: a delegated grant can die silently, and for a portal whose password-reset mails ride on this connection that must surface as a warning rather than as a support ticket weeks later. It decided whether to warn from last_error -- but last_error has a second writer. OAuthCodeFlowBroker::refresh() records a dead grant and notifies nobody, and freshAccessToken() reaches it from every send. So on an installation that is actually sending mail the send got there first, the command read the column as "already told them", and the warning never went out. last_error is cleared only by a successful refresh, which a dead grant never has, so it never went out later either. The alarm worked on installations that were not using the mailbox and failed on the ones that were.

The anti-nag rule is not the problem and does not change: one notification per broken state is still all anybody gets. The problem is that one column was answering two questions, which the table's own comment describes -- "what the settings page's warning and the admin notification read". The warning wants "is this connection broken", and any writer may answer it, which is why the settings page turning red on a failed send is correct and stays. The notification wants "have the admins been told", and only the notifier can answer that.

broken_notified_at is stamped when the command notifies, and the command asks that instead. It is cleared wherever last_error is cleared -- a successful refresh, a disconnect, a changed client id -- and those three sites now call clearFailure() rather than nulling two columns each, because a connection left healthy but still marked "already told them" would go quiet the next time it died, and a fourth caller is exactly how the first one happened. The send path still records the failure and still notifies nobody: a transport is not a place to decide who gets alarmed.

Verified before merging: 27 passed on the merged tree, 2 failed / 25 passed with app/ reset and the migration and tests kept. The recovery test is green either way by design. This touches the same command and broker as #1739 and the follow-up to it, so the merged result was read rather than trusted: the refresh reporting sits in the try and the notify guard in the catch, they do not interact, and refreshSerially() re-reads the row before refreshing so the broken_notified_at the catch reads is the stored one -- while a stand-aside throws nothing and never reaches the catch at all.

Note for the next release's upgrade notes: this adds a migration, so "nothing to do beyond dropping in the files" no longer holds.

Reported and fixed by @denkfabrik-li.
2026-08-28 18:04:37 -03:00

298 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* The authorization-code machinery every OAuth mail provider shares:
* exchanging the callback's code, refreshing on demand, storing what
* came back. Providers differ only in their endpoints, their scope
* string, and how the consent URL is parameterized — which is exactly
* the surface the abstract methods cover.
*
* Plain HTTP against the token endpoints rather than vendor SDKs — the
* project ships none, and two POST requests per provider do not justify
* one.
*/
abstract class OAuthCodeFlowBroker implements MailOAuthBroker
{
/** Refresh when the access token has less life left than this. */
private const EXPIRY_MARGIN_SECONDS = 120;
abstract public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string;
/** The provider's OAuth token endpoint for this connection. */
abstract protected function tokenEndpoint(MailOAuthConnection $connection): string;
/** The scope string this provider's tokens are requested with. */
abstract protected function scope(): string;
public function exchange(MailOAuthConnection $connection, string $code, string $redirectUri): void
{
$response = Http::asForm()->post($this->tokenEndpoint($connection), [
'client_id' => (string) $connection->client_id,
'client_secret' => (string) $connection->client_secret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri,
'scope' => $this->scope(),
]);
if ($response->failed()) {
throw $this->failure($response);
}
$this->storeTokens($connection, $response);
}
public function refresh(MailOAuthConnection $connection): void
{
$refreshToken = $connection->refresh_token;
if (! is_string($refreshToken) || $refreshToken === '') {
throw new MailOAuthException('No mailbox is connected.', needsReconnect: true);
}
$response = Http::asForm()->post($this->tokenEndpoint($connection), [
'client_id' => (string) $connection->client_id,
'client_secret' => (string) $connection->client_secret,
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'scope' => $this->scope(),
]);
if ($response->failed()) {
$failure = $this->failure($response);
// Only a dead grant is worth alarming the admin over; a
// transient endpoint problem heals on the next attempt and
// must not paint the settings page red in the meantime.
if ($failure->needsReconnect) {
$connection->last_error = $failure->getMessage();
$connection->save();
}
throw $failure;
}
$this->storeTokens($connection, $response);
}
/**
* The scheduled refresh, holding the same lock a send would.
*
* freshAccessToken() takes that lock because a refresh token is good
* for exactly one use, and it names this command as one of the racers:
* "a worker racing the nightly refresh command means the slower one
* spends a token the faster one has already replaced", which the
* provider answers with an invalid_grant indistinguishable from a
* revoked grant. The command was doing its refresh outside the lock,
* so it was the other half of that race rather than a party to it.
*
* Unlike freshAccessToken() this refreshes a token that is still
* usable, which is the point of the daily run: a delegated refresh
* token dies of disuse, and the refresh keeps the window sliding.
*
* Taken rather than waited for, unlike the send path: nobody is
* standing at a screen here, and a held lock means somebody is
* refreshing this very connection right now — which slides the window
* and establishes its health just as well as doing it again would.
* Spending the token behind them is the false alarm the lock exists to
* prevent.
*
* Returns false in that case, so the scheduled command can report
* standing aside instead of announcing a refresh that never happened.
*/
public function refreshSerially(MailOAuthConnection $connection): bool
{
$lock = $this->refreshLock($connection);
if (! $lock->get()) {
return false;
}
try {
// Re-read first: the winner may have stored tokens while this
// call was waiting, and refreshing the copy walked in with
// would spend a refresh token that is no longer current.
$connection->refresh();
$this->refresh($connection);
return true;
} finally {
$lock->release();
}
}
public function freshAccessToken(MailOAuthConnection $connection): string
{
if ($this->stillUsable($connection)) {
return (string) $connection->access_token;
}
// Both providers rotate the refresh token as they hand out a new
// access token, so a refresh token is good for exactly one use.
// Two queue workers reaching an expired token at the same moment —
// or a worker racing the nightly refresh command — means the slower
// one spends a token the faster one has already replaced. The
// provider answers that with invalid_grant, which is the same thing
// it says about a genuinely revoked grant: last_error gets written,
// the settings page turns red, and every admin is told to go and
// re-consent a connection that was never broken.
//
// So refresh one at a time per connection, and make whoever waited
// re-read the row instead of trusting the copy it walked in with: by
// the time the lock is theirs, the winner has already stored a token
// they can just use.
$lock = $this->refreshLock($connection);
try {
$lock->block(15);
} catch (LockTimeoutException) {
// Fifteen seconds means something is wrong with the lock rather
// than with the provider. Racing is a false alarm; not sending is
// a lost message. Take the race.
$this->refresh($connection);
return (string) $connection->access_token;
}
try {
// Eloquent's refresh(), re-reading the row — not this class's,
// which is the thing the lock exists to serialise.
$connection->refresh();
if ($this->stillUsable($connection)) {
return (string) $connection->access_token;
}
$this->refresh($connection);
return (string) $connection->access_token;
} finally {
$lock->release();
}
}
/** Whether the stored access token has enough life left to send with. */
/**
* One refresh at a time per connection, whoever is asking. The TTL
* outlives a token request and releases the claim if the holder dies
* mid-flight.
*/
private function refreshLock(MailOAuthConnection $connection): Lock
{
return Cache::lock('mail-oauth-refresh:'.$connection->provider->value, 30);
}
private function stillUsable(MailOAuthConnection $connection): bool
{
$token = $connection->access_token;
$expiresAt = $connection->token_expires_at;
return is_string($token)
&& $token !== ''
&& $expiresAt !== null
&& $expiresAt->gt(now()->addSeconds(self::EXPIRY_MARGIN_SECONDS));
}
private function storeTokens(MailOAuthConnection $connection, Response $response): void
{
$accessToken = $response->json('access_token');
$expiresIn = $response->json('expires_in');
if (! is_string($accessToken) || $accessToken === '') {
throw new MailOAuthException('The token response did not include an access token.');
}
$connection->access_token = $accessToken;
$connection->token_expires_at = now()->addSeconds(is_numeric($expiresIn) ? (int) $expiresIn : 3600);
// Microsoft rotates the refresh token on every use; Google hands
// one out only on the initial consent. Same rule covers both: a
// response without one keeps what is already stored.
$newRefreshToken = $response->json('refresh_token');
if (is_string($newRefreshToken) && $newRefreshToken !== '') {
$connection->refresh_token = $newRefreshToken;
}
$email = $this->emailFromIdToken($response->json('id_token'));
if ($email !== null) {
$connection->account_email = $email;
}
$connection->last_refreshed_at = now();
$connection->clearFailure();
$connection->save();
}
/**
* The signed-in mailbox's address, read from the id_token's claims
* (`preferred_username` on Microsoft, `email` on Google).
*
* Deliberately without signature verification: this token arrived in
* the token endpoint's own TLS response — not from the browser — and
* feeds a display/From value, not an authentication decision. That
* is the trade that lets sending work with the send scope alone,
* with no userinfo permission.
*/
private function emailFromIdToken(mixed $idToken): ?string
{
if (! is_string($idToken) || substr_count($idToken, '.') !== 2) {
return null;
}
[, $payload] = explode('.', $idToken);
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
if ($decoded === false) {
return null;
}
$claims = json_decode($decoded, true);
if (! is_array($claims)) {
return null;
}
foreach (['preferred_username', 'email'] as $claim) {
$value = $claims[$claim] ?? null;
if (is_string($value) && str_contains($value, '@')) {
return $value;
}
}
return null;
}
private function failure(Response $response): MailOAuthException
{
$error = $response->json('error');
$description = $response->json('error_description');
$message = is_string($description) && $description !== ''
? $description
: (is_string($error) && $error !== '' ? $error : 'The token endpoint answered HTTP '.$response->status().'.');
// Both vendors speak RFC 6749 here. invalid_grant covers
// everything that kills a grant: revoked consent, a password
// reset or Conditional Access change (Microsoft), the 7-day
// testing-status expiry (Google). invalid_client means the app
// registration itself (secret expired?) — also unfixable by
// retry.
$needsReconnect = in_array($error, ['invalid_grant', 'invalid_client'], true);
return new MailOAuthException($message, needsReconnect: $needsReconnect);
}
}