Skip to content

Commit

Permalink
FCM: Update to new firebase API v1
Browse files Browse the repository at this point in the history
  • Loading branch information
brianstoop authored and pprkut committed Feb 27, 2024
1 parent 499e7d7 commit 634c179
Show file tree
Hide file tree
Showing 11 changed files with 1,583 additions and 49 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"require-dev": {
"lunr/halo": "dev-master",
"phpunit/phpunit": ">=9.0 <9.6",
"mockery/mockery": "~1.5",
"ext-xdebug": ">=3.1"
},
"config": {
Expand Down
8 changes: 8 additions & 0 deletions decomposer.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,13 @@
"prefix": "WpOrg\\Requests",
"search-path": "/src/"
}
},
"Mockery": {
"url": "https://github.com/mockery/mockery.git",
"version": "1.6.7",
"psr0": {
"path": "/library/"
},
"development-only": true
}
}
241 changes: 224 additions & 17 deletions src/Lunr/Vortex/FCM/FCMDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@

namespace Lunr\Vortex\FCM;

use BadMethodCallException;
use DateTimeImmutable;
use InvalidArgumentException;
use Lcobucci\JWT\Encoding\ChainedFormatter;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\Builder;
use Lunr\Vortex\PushNotificationDispatcherInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use UnexpectedValueException;
use WpOrg\Requests\Exception as RequestsException;
use WpOrg\Requests\Response;
use WpOrg\Requests\Session;
Expand All @@ -23,10 +32,28 @@
class FCMDispatcher implements PushNotificationDispatcherInterface
{
/**
* Push Notification authentication token.
* Push Notification Oauth token.
* @var string
*/
protected string $auth_token;
protected ?string $oauth_token;

/**
* FCM id of the project.
* @var ?string
*/
protected ?string $project_id;

/**
* FCM client email of the project.
* @var ?string
*/
protected ?string $client_email;

/**
* FCM id of the project.
* @var ?string
*/
protected ?string $private_key;

/**
* Shared instance of the Requests\Session class.
Expand All @@ -45,7 +72,19 @@ class FCMDispatcher implements PushNotificationDispatcherInterface
* Url to send the FCM push notification to.
* @var string
*/
private const GOOGLE_SEND_URL = 'https://fcm.googleapis.com/fcm/send';
private const GOOGLE_SEND_URL = 'https://fcm.googleapis.com/v1/projects/';

/**
* Url to fetch the OAuth2 token.
* @var string
*/
private const GOOGLE_OAUTH_URL = 'https://oauth2.googleapis.com/token';

/**
* Default lifetime for the OAuth token.
* @var string
*/
private const DEFAULT_OAUTH_LIFETIME = '+10 minutes';

/**
* Constructor.
Expand All @@ -55,21 +94,99 @@ class FCMDispatcher implements PushNotificationDispatcherInterface
*/
public function __construct(Session $http, LoggerInterface $logger)
{
$this->http = $http;
$this->logger = $logger;
$this->auth_token = '';
$this->http = $http;
$this->logger = $logger;
$this->oauth_token = NULL;
$this->project_id = NULL;
$this->client_email = NULL;
$this->private_key = NULL;
}

/**
* Destructor.
*/
public function __destruct()
{
unset($this->auth_token);
unset($this->oauth_token);
unset($this->project_id);
unset($this->client_email);
unset($this->private_key);
unset($this->http);
unset($this->logger);
}

/**
* Set the FCM project id for sending notifications.
*
* @param string $project_id The id of the FCM project
*
* @return $this
*/
public function set_project_id(string $project_id): static
{
$this->project_id = $project_id;

return $this;
}

/**
* Set the FCM client email for sending notifications.
*
* @param string $client_email The client email of the FCM project
*
* @return $this
*/
public function set_client_email(string $client_email): static
{
$this->client_email = $client_email;

return $this;
}

/**
* Set the FCM private key for sending notifications.
*
* @param string $private_key The private key of the FCM project
*
* @return $this
*/
public function set_private_key(string $private_key): static
{
$this->private_key = $private_key;

return $this;
}

/**
* Set a token to authenticate with.
*
* @param string $token The OAuth token to use
*
* @return $this
*/
public function set_oauth_token(string $token): static
{
$this->oauth_token = $token;

return $this;
}

/**
* Request and set an oauth token from FCM.
*
* @param string $oauth_lifetime Relative time as a string for strtotime() to parse into an expiry timestamp.
*
* @see https://www.php.net/manual/en/datetime.formats.php#datetime.formats.relative
*
* @return $this
*/
public function configure_oauth_token(string $oauth_lifetime = self::DEFAULT_OAUTH_LIFETIME): static
{
$this->set_oauth_token($this->get_oauth_token($oauth_lifetime));

return $this;
}

/**
* Getter for FCMResponse.
*
Expand Down Expand Up @@ -105,9 +222,25 @@ public function push(object $payload, array &$endpoints): FCMResponse
throw new InvalidArgumentException('No endpoints provided!');
}

if ($this->oauth_token === NULL)
{
$context = [ 'endpoint' => $endpoints[0] ];
$this->logger->warning('Tried to push FCM notification to {endpoint} but wasn\'t authenticated.', $context);

return $this->get_response($this->get_new_response_object_for_failed_request(401), $this->logger, $endpoints[0], $payload->get_payload());
}

if ($this->project_id === NULL)
{
$context = [ 'endpoint' => $endpoints[0] ];
$this->logger->warning('Tried to push FCM notification to {endpoint} but project id is not provided.', $context);

return $this->get_response($this->get_new_response_object_for_failed_request(400), $this->logger, $endpoints[0], $payload->get_payload());
}

$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'key=' . $this->auth_token,
'Authorization' => 'Bearer ' . $this->oauth_token,
];

$tmp_payload = json_decode($payload->get_payload(), TRUE);
Expand All @@ -123,7 +256,7 @@ public function push(object $payload, array &$endpoints): FCMResponse
'connect_timeout' => 15 // timeout in seconds
];

$http_response = $this->http->post(self::GOOGLE_SEND_URL, $headers, $json_payload, $options);
$http_response = $this->http->post(self::GOOGLE_SEND_URL . $this->project_id . '/messages:send', $headers, $json_payload, $options);
}
catch (RequestsException $e)
{
Expand All @@ -144,29 +277,103 @@ public function push(object $payload, array &$endpoints): FCMResponse
}

/**
* Set the the auth token for the http headers.
* Get the oauth token for the http headers.
*
* @param string $auth_token The auth token for the fcm push notifications
* @param string $oauth_lifetime Relative time as a string for strtotime() to parse into an expiry timestamp
*
* @return FCMDispatcher Self reference
* @see https://www.php.net/manual/en/datetime.formats.php#datetime.formats.relative
*
* @return string The OAuth_token
*/
public function set_auth_token(string $auth_token): self
public function get_oauth_token(string $oauth_lifetime = self::DEFAULT_OAUTH_LIFETIME): string
{
$this->auth_token = $auth_token;
if (strtotime($oauth_lifetime) === FALSE)
{
throw new InvalidArgumentException('Invalid oauth lifetime!');
}

return $this;
if ($this->client_email === NULL)
{
throw new BadMethodCallException('Requesting token failed: No client email provided');
}

if ($this->private_key === NULL)
{
throw new BadMethodCallException('Requesting token failed: No private key provided');
}

$issued_at = new DateTimeImmutable();

$token_builder = new Builder(new JoseEncoder(), ChainedFormatter::default());

$token = $token_builder->issuedBy($this->client_email)
->permittedFor('https://oauth2.googleapis.com/token')
->issuedAt($issued_at)
->expiresAt($issued_at->modify($oauth_lifetime))
->withClaim('scope', 'https://www.googleapis.com/auth/firebase.messaging')
->withHeader('alg', 'RS2256')
->withHeader('typ', 'JWT')
->getToken(new Sha256(), InMemory::plainText($this->private_key));

$headers = [
'Content-Type' => 'application/json'
];

$payload = [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $token->toString(),
];

try
{
$http_response = $this->http->post(self::GOOGLE_OAUTH_URL, $headers, json_encode($payload, JSON_UNESCAPED_UNICODE), []);
}
catch (RequestsException $e)
{
$context = [ 'message' => $e->getMessage() ];
$this->logger->warning('Fetching OAuth token for FCM notification(s) failed: {message}', $context);

throw new RuntimeException('Fetching OAuth token for FCM notification(s) failed', 0, $e);
}

$response_body = json_decode($http_response->body, TRUE);

if (json_last_error() !== JSON_ERROR_NONE)
{
$context = [ 'message' => json_last_error_msg() ];
$this->logger->warning('Processing json response for fetching OAuth token for FCM notification(s) failed: {message}', $context);

$message = 'Processing json response for fetching OAuth token for FCM notification(s) failed: ' . $context['message'];
throw new UnexpectedValueException($message);
}

if (!array_key_exists('access_token', $response_body))
{
$error_msg = $response_body['error_description'] ?? 'No access token in the response body';

$context = [ 'error' => $error_msg ];
$this->logger->warning('Fetching OAuth token for FCM notification(s) failed: {error}', $context);

throw new UnexpectedValueException('Fetching OAuth token for FCM notification(s) failed: ' . $error_msg);
}

return $response_body['access_token'];
}

/**
* Get a Requests\Response object for a failed request.
*
* @param int $http_code Set http code for the request.
*
* @return Response New instance of a Requests\Response object.
*/
protected function get_new_response_object_for_failed_request(): Response
protected function get_new_response_object_for_failed_request(?int $http_code = NULL): Response
{
$http_response = new Response();

$http_response->url = self::GOOGLE_SEND_URL;
$http_response->url = self::GOOGLE_SEND_URL . $this->project_id . '/messages:send';

$http_response->status_code = $http_code ?? FALSE;

return $http_response;
}
Expand Down
34 changes: 30 additions & 4 deletions src/Lunr/Vortex/FCM/Tests/FCMDispatcherBaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,35 @@ public function testRequestsSessionIsSetCorrectly(): void
}

/**
* Test that the auth token is set to an empty string by default.
* Test that the OAuth token is set to null by default.
*/
public function testAuthTokenIsEmptyString(): void
public function testOAuthTokenIsSetToNull(): void
{
$this->assertPropertyEquals('auth_token', '');
$this->assertPropertyEquals('oauth_token', NULL);
}

/**
* Test that the project_id is set to null by default.
*/
public function testProjectIdIsSetToNull(): void
{
$this->assertPropertyEquals('project_id', NULL);
}

/**
* Test that the client_email is set to null by default.
*/
public function testClientEmailIsSetToNull(): void
{
$this->assertPropertyEquals('client_email', NULL);
}

/**
* Test that the private_key is set to null by default.
*/
public function testPrivateKeyIsSetToNull(): void
{
$this->assertPropertyEquals('private_key', NULL);
}

/**
Expand All @@ -45,12 +69,14 @@ public function testAuthTokenIsEmptyString(): void
*/
public function testGetNewResponseObjectForFailedRequest(): void
{
$this->set_reflection_property_value('project_id', 'fcm-project');

$method = $this->get_accessible_reflection_method('get_new_response_object_for_failed_request');

$result = $method->invoke($this->class);

$this->assertInstanceOf('WpOrg\Requests\Response', $result);
$this->assertEquals('https://fcm.googleapis.com/fcm/send', $result->url);
$this->assertEquals('https://fcm.googleapis.com/v1/projects/fcm-project/messages:send', $result->url);
}

/**
Expand Down
Loading

0 comments on commit 634c179

Please sign in to comment.