mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
eda8091aef
Both fixes are follow-ups to the mail providers @denkfabrik-li added in #1679. A refresh token is good for exactly one use — Microsoft and Google both retire it as they issue the next one. Two queue workers finding the same expired access token would therefore both spend it, and the loser gets invalid_grant back. That is the same answer a revoked grant gives, so a healthy connection would be marked broken, painted red on the settings page and mailed to every admin. Refreshes now hold a per-connection lock and whoever waits re-reads the row, which normally means finding a token the winner already stored and not refreshing at all. The Connect button read the provider dropdown, but the flow it starts uses the saved provider. On an installation with both vendors registered, switching without saving would open the wrong consent screen. The dropdown now counts as an unsaved change like any other field, which also gives it the right "save first" hint for free.
240 lines
8.9 KiB
PHP
240 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\Mail;
|
|
|
|
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);
|
|
}
|
|
|
|
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 = Cache::lock('mail-oauth-refresh:'.$connection->provider->value, 30);
|
|
|
|
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. */
|
|
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);
|
|
}
|
|
}
|