Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
23 changes: 23 additions & 0 deletions docs/cloud-messaging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------

Expand Down Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions src/Exception/Messaging/SenderIdMismatch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Kreait\Firebase\Exception\Messaging;

use Kreait\Firebase\Exception\HasErrors;
use Kreait\Firebase\Exception\MessagingException;
use Kreait\Firebase\Exception\RuntimeException;

/**
* The registration token is registered to a different Firebase project than the
* project the message is sent from. This is a permanent, per-token failure: the
* token should be treated like an unregistered token and removed from the list
* of message targets.
*/
final class SenderIdMismatch extends RuntimeException implements MessagingException
{
use HasErrors;

/**
* @internal
*
* @param array<mixed> $errors
*/
public function withErrors(array $errors): self
{
$new = new self(message: $this->getMessage(), previous: $this->getPrevious());
$new->errors = $errors;

return $new;
}
}
30 changes: 29 additions & 1 deletion src/Exception/MessagingApiExceptionConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@
use Psr\Http\Message\ResponseInterface;
use Throwable;

use function is_array;
use function is_numeric;

/**
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<mixed> $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');
Expand Down
4 changes: 3 additions & 1 deletion src/Messaging/SendReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down
51 changes: 51 additions & 0 deletions tests/Unit/Exception/MessagingApiExceptionConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
Expand Down
40 changes: 40 additions & 0 deletions tests/Unit/Messaging/MulticastSendReportTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Kreait\Firebase\Tests\Unit\Messaging;

use Iterator;
use Kreait\Firebase\Exception\Messaging\NotFound;
use Kreait\Firebase\Exception\Messaging\SenderIdMismatch;
use Kreait\Firebase\Exception\MessagingException;
use Kreait\Firebase\Messaging\MessageTarget;
use Kreait\Firebase\Messaging\MulticastSendReport;
use Kreait\Firebase\Messaging\SendReport;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* @internal
*/
final class MulticastSendReportTest extends TestCase
{
#[DataProvider('unknownTokenErrors')]
public function testItReturnsUnknownTokens(MessagingException $error): void
{
$target = MessageTarget::with(MessageTarget::TOKEN, 'token-from-another-project');
$sendReport = SendReport::failure($target, $error);
$report = MulticastSendReport::withItems([$sendReport]);

$this->assertSame(['token-from-another-project'], $report->unknownTokens());
}

/**
* @return Iterator<string, array{MessagingException}>
*/
public static function unknownTokenErrors(): Iterator
{
yield 'not found' => [new NotFound('Not found')];
yield 'sender ID mismatch' => [new SenderIdMismatch('SenderId mismatch')];
}
}