mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-19 10:05:11 +00:00
ff26fac9c5
#1739 put the nightly OAuth refresh under the same lock a send holds, which is right -- but standing aside for the lock holder still printed "Refreshed <provider> (<account>)". No token request was made, so the line describes something that did not happen, and scheduler output is read precisely by somebody trying to work out what did.
refreshSerially() now answers whether it refreshed, and the command says which of the two happened. Standing aside is a healthy outcome: somebody else is refreshing this very connection, which slides the token window just as well as doing it again would. It is just not a refresh, and it should not claim to be one.
The existing test for the stand-aside now asserts the output too, and it fails against the old message.
Same reasoning as d8ef21b, which said when the worker check was skipped rather than skipping it quietly.
298 lines
11 KiB
PHP
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->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);
|
|
}
|
|
}
|