mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 01:25:09 +00:00
02eafb473b
freshAccessToken() serialises refreshes per connection, and its comment says why: both providers rotate the refresh token as they hand out an access token, so the token is good for exactly one use, and "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." The nightly refresh command called refresh() directly, outside that lock. It was the racer the comment names, not a party to the arrangement it describes. It now goes through refreshSerially(), which takes the same lock -- named once, in one place, for both callers -- re-reads the row inside it, and refreshes. Unlike freshAccessToken() it refreshes a token that is still usable, which is the point of the daily run: a delegated refresh token dies of disuse and this keeps the window sliding. The lock is taken rather than waited for, unlike the send path. Nobody is standing at a screen for a scheduled job, 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. One test: with the lock held, the command sends no token request and leaves the connection untouched. Without the fix it spends the refresh token the holder is already spending.
293 lines
11 KiB
PHP
293 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.
|
|
*/
|
|
public function refreshSerially(MailOAuthConnection $connection): void
|
|
{
|
|
$lock = $this->refreshLock($connection);
|
|
|
|
if (! $lock->get()) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
} 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->last_error = null;
|
|
$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);
|
|
}
|
|
}
|