diff --git a/CHANGELOG.md b/CHANGELOG.md index 790906270..4fc16808a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Please update your remote URL if you have forked or cloned the repository. ## Unreleased +## Messaging + +* FCM `403 PERMISSION_DENIED` responses with the error code `SENDER_ID_MISMATCH` are now + converted to a dedicated `Kreait\Firebase\Exception\Messaging\SenderIdMismatch` exception instead of + the more generic `AuthenticationError`. + ## 8.4.0 - 2026-08-05 * Added support for `guzzlehttp/guzzle:^8.0`, `guzzlehttp/psr7:^3.0` and `guzzlehttp/promises:^3.0`. diff --git a/docs/cloud-messaging.rst b/docs/cloud-messaging.rst index 77fb5915b..97165870d 100644 --- a/docs/cloud-messaging.rst +++ b/docs/cloud-messaging.rst @@ -779,6 +779,27 @@ syntactically correct, this usually has one of the following reasons: echo $e->token(); } +Mismatched registration tokens +------------------------------ + +If a registration token is registered to a *different* Firebase project than the project +you are using to send the message, the FCM API rejects the message with a +``SENDER_ID_MISMATCH`` error. Like an unknown token, this is a permanent failure for +this token - it will never be reachable from your project, so it should be removed +from your list of message targets. In contrast to other authentication errors, it does +not indicate a problem with your project's credentials. + +.. code-block:: php + + use Kreait\Firebase\Exception\Messaging\SenderIdMismatch; + + try { + $messaging->send($message); + } catch (SenderIdMismatch $e) { + echo $e->getMessage(); + print_r($e->errors()); + } + Quota exceeded -------------- @@ -836,6 +857,8 @@ Error handling example $messaging->send($message); } catch (MessagingErrors\NotFound $e) { echo 'The target device could not be found.'; + } catch (MessagingErrors\SenderIdMismatch $e) { + echo 'The target device belongs to a different Firebase project.'; } catch (MessagingErrors\InvalidMessage $e) { echo 'The given message is malformatted.'; } catch (MessagingErrors\ServerUnavailable $e) { diff --git a/src/Exception/Messaging/SenderIdMismatch.php b/src/Exception/Messaging/SenderIdMismatch.php new file mode 100644 index 000000000..cb4524359 --- /dev/null +++ b/src/Exception/Messaging/SenderIdMismatch.php @@ -0,0 +1,33 @@ + $errors + */ + public function withErrors(array $errors): self + { + $new = new self(message: $this->getMessage(), previous: $this->getPrevious()); + $new->errors = $errors; + + return $new; + } +} diff --git a/src/Exception/MessagingApiExceptionConverter.php b/src/Exception/MessagingApiExceptionConverter.php index e512626e5..449880ef8 100644 --- a/src/Exception/MessagingApiExceptionConverter.php +++ b/src/Exception/MessagingApiExceptionConverter.php @@ -15,6 +15,7 @@ use Kreait\Firebase\Exception\Messaging\MessagingError; use Kreait\Firebase\Exception\Messaging\NotFound; use Kreait\Firebase\Exception\Messaging\QuotaExceeded; +use Kreait\Firebase\Exception\Messaging\SenderIdMismatch; use Kreait\Firebase\Exception\Messaging\ServerError; use Kreait\Firebase\Exception\Messaging\ServerUnavailable; use Kreait\Firebase\Http\ErrorResponseParser; @@ -23,6 +24,7 @@ use Psr\Http\Message\ResponseInterface; use Throwable; +use function is_array; use function is_numeric; /** @@ -74,11 +76,17 @@ public function convertResponse(ResponseInterface $response, ?Throwable $previou break; case StatusCode::STATUS_UNAUTHORIZED: - case StatusCode::STATUS_FORBIDDEN: $convertedError = new AuthenticationError($message, previous: $previous); break; + case StatusCode::STATUS_FORBIDDEN: + $convertedError = $this->isSenderIdMismatch($errors, $message) + ? new SenderIdMismatch($message, previous: $previous) + : new AuthenticationError($message, previous: $previous); + + break; + case StatusCode::STATUS_NOT_FOUND: $convertedError = new NotFound($message, previous: $previous); @@ -148,6 +156,26 @@ private function convertGuzzleRequestException(RequestException $e): MessagingEx return new MessagingError(message: $e->getMessage(), previous: $e); } + /** + * @see https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + * + * @param array $errors + */ + private function isSenderIdMismatch(array $errors, string $message): bool + { + $details = $errors['error']['details'] ?? []; + + if (is_array($details)) { + foreach ($details as $detail) { + if (is_array($detail) && ($detail['errorCode'] ?? null) === 'SENDER_ID_MISMATCH') { + return true; + } + } + } + + return mb_strtolower($message) === 'senderid mismatch'; + } + private function getRetryAfter(ResponseInterface $response): ?DateTimeImmutable { $retryAfter = $response->getHeaderLine('Retry-After'); diff --git a/src/Messaging/SendReport.php b/src/Messaging/SendReport.php index 5842c62e7..4d8a2045f 100644 --- a/src/Messaging/SendReport.php +++ b/src/Messaging/SendReport.php @@ -6,6 +6,7 @@ use Kreait\Firebase\Exception\Messaging\InvalidMessage; use Kreait\Firebase\Exception\Messaging\NotFound; +use Kreait\Firebase\Exception\Messaging\SenderIdMismatch; use Kreait\Firebase\Exception\MessagingException; use function preg_match; @@ -75,7 +76,8 @@ public function messageWasInvalid(): bool public function messageWasSentToUnknownToken(): bool { - return $this->error instanceof NotFound; + return $this->error instanceof NotFound + || $this->error instanceof SenderIdMismatch; } /** diff --git a/tests/Unit/Exception/MessagingApiExceptionConverterTest.php b/tests/Unit/Exception/MessagingApiExceptionConverterTest.php index 7c10b6eae..27a4a9da0 100644 --- a/tests/Unit/Exception/MessagingApiExceptionConverterTest.php +++ b/tests/Unit/Exception/MessagingApiExceptionConverterTest.php @@ -18,6 +18,7 @@ use Kreait\Firebase\Exception\Messaging\MessagingError; use Kreait\Firebase\Exception\Messaging\NotFound; use Kreait\Firebase\Exception\Messaging\QuotaExceeded; +use Kreait\Firebase\Exception\Messaging\SenderIdMismatch; use Kreait\Firebase\Exception\Messaging\ServerError; use Kreait\Firebase\Exception\Messaging\ServerUnavailable; use Kreait\Firebase\Exception\MessagingApiExceptionConverter; @@ -107,6 +108,56 @@ public static function createRequestException(int $code, string $identifier): Re ); } + public function testItConvertsASenderIdMismatchResponseIdentifiedByItsErrorCode(): void + { + // Error shape as returned by the FCM HTTP v1 API + // https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + $response = new Response(403, [], Json::encode([ + 'error' => [ + 'code' => 403, + 'message' => 'SenderId mismatch', + 'status' => 'PERMISSION_DENIED', + 'details' => [ + [ + '@type' => 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode' => 'SENDER_ID_MISMATCH', + ], + ], + ], + ])); + + $converted = $this->converter->convertResponse($response); + + $this->assertInstanceOf(SenderIdMismatch::class, $converted); + $this->assertNotEmpty($converted->errors()); + } + + public function testItConvertsASenderIdMismatchResponseIdentifiedByItsMessage(): void + { + $response = new Response(403, [], Json::encode([ + 'error' => [ + 'code' => 403, + 'message' => 'SenderId mismatch', + 'status' => 'PERMISSION_DENIED', + ], + ])); + + $this->assertInstanceOf(SenderIdMismatch::class, $this->converter->convertResponse($response)); + } + + public function testItConvertsOtherForbiddenResponsesToAuthenticationErrors(): void + { + $response = new Response(403, [], Json::encode([ + 'error' => [ + 'code' => 403, + 'message' => 'The caller does not have permission', + 'status' => 'PERMISSION_DENIED', + ], + ])); + + $this->assertInstanceOf(AuthenticationError::class, $this->converter->convertResponse($response)); + } + public function testItKnowsWhenToRetryAfterWithSeconds(): void { $response = new Response(429, ['Retry-After' => '60']); diff --git a/tests/Unit/Messaging/MulticastSendReportTest.php b/tests/Unit/Messaging/MulticastSendReportTest.php new file mode 100644 index 000000000..d465f90ae --- /dev/null +++ b/tests/Unit/Messaging/MulticastSendReportTest.php @@ -0,0 +1,40 @@ +assertSame(['token-from-another-project'], $report->unknownTokens()); + } + + /** + * @return Iterator + */ + public static function unknownTokenErrors(): Iterator + { + yield 'not found' => [new NotFound('Not found')]; + yield 'sender ID mismatch' => [new SenderIdMismatch('SenderId mismatch')]; + } +}