From 9c499743d23f6019f9fcb8564852b6059d675732 Mon Sep 17 00:00:00 2001 From: QuenHengLee <72889246+QuenHengLee@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:27:16 +0800 Subject: [PATCH 1/3] Messaging: Convert 403 SENDER_ID_MISMATCH responses to a dedicated exception FCM rejects messages sent to a registration token that belongs to a different Firebase project with 403 PERMISSION_DENIED and the error code SENDER_ID_MISMATCH. Until now, this response was converted to the same AuthenticationError as 401 responses, although the two conditions require opposite handling: a 401 is a service-level credential problem, while a sender ID mismatch is a permanent, per-token failure that should be handled like an unregistered token. MessagingApiExceptionConverter now converts 403 responses carrying the SENDER_ID_MISMATCH error code (or the "SenderId mismatch" message) to a new SenderIdMismatch exception. All other 401/403 responses are still converted to AuthenticationError. This mirrors the official Admin SDKs, which expose this condition as messaging/sender-id-mismatch (Node.js), MessagingErrorCode.SENDER_ID_MISMATCH (Java), and IsSenderIDMismatch() (Go). --- CHANGELOG.md | 7 +++ docs/cloud-messaging.rst | 23 +++++++++ src/Exception/Messaging/SenderIdMismatch.php | 33 ++++++++++++ .../MessagingApiExceptionConverter.php | 30 ++++++++++- .../MessagingApiExceptionConverterTest.php | 51 +++++++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/Exception/Messaging/SenderIdMismatch.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 790906270..c4e085a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ 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`. A sender ID mismatch is a permanent, per-token failure (the + token belongs to a different Firebase project) and not a problem with the project's credentials, so + it can now be handled like an unregistered token. Other `401`/`403` responses are still converted + to `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/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']); From 78e1ef7a6a994a6e593bed376acd273a36816123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Gamez?= Date: Tue, 18 Aug 2026 00:00:07 +0200 Subject: [PATCH 2/3] Treat sender ID mismatches as unknown tokens --- src/Messaging/SendReport.php | 4 +- .../Messaging/MulticastSendReportTest.php | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Messaging/MulticastSendReportTest.php 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/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')]; + } +} From 53720319391e867d9b60a802248b348fedf4030b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Gamez?= Date: Tue, 18 Aug 2026 00:08:15 +0200 Subject: [PATCH 3/3] Update changelog --- CHANGELOG.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e085a32..4fc16808a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,11 @@ 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 +## 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`. A sender ID mismatch is a permanent, per-token failure (the - token belongs to a different Firebase project) and not a problem with the project's credentials, so - it can now be handled like an unregistered token. Other `401`/`403` responses are still converted - to `AuthenticationError`. + the more generic `AuthenticationError`. ## 8.4.0 - 2026-08-05