Migrate FCM from legacy v1 API to HTTP v2 with service account auth
All checks were successful
Create Release / build (push) Successful in 3s

Replace deprecated FCM server key authentication with Google service
account OAuth2 flow. The class now creates a signed JWT from the
service account credentials, exchanges it for a short-lived access
token (cached via WordPress transients), and sends messages to the
FCM v2 endpoint (projects/{id}/messages:send).

Settings page updated: FCM Server Key field replaced with Firebase
Project ID + Service Account JSON textarea with validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-06 14:04:33 -08:00
parent 826fd3ae39
commit 5adfa694c1
2 changed files with 208 additions and 51 deletions

View File

@@ -1,27 +1,33 @@
<?php
/**
* Firebase Cloud Messaging (FCM) Integration
* Firebase Cloud Messaging (FCM) Integration — HTTP v2 API
*
* Handles push notifications to mobile devices via FCM
* Handles push notifications to mobile devices via FCM using
* service account credentials and OAuth2 access tokens.
*/
class TWP_FCM {
private $server_key;
private $fcm_url = 'https://fcm.googleapis.com/fcm/send';
private $project_id;
private $service_account;
private $fcm_url_template = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';
/**
* Constructor
*/
public function __construct() {
$this->server_key = get_option('twp_fcm_server_key', '');
$this->project_id = get_option('twp_fcm_project_id', '');
$sa_json = get_option('twp_fcm_service_account_json', '');
if (!empty($sa_json)) {
$this->service_account = json_decode($sa_json, true);
}
}
/**
* Send push notification to user's devices
*/
public function send_notification($user_id, $title, $body, $data = array(), $data_only = false) {
if (empty($this->server_key)) {
error_log('TWP FCM: Server key not configured');
if (empty($this->project_id) || empty($this->service_account)) {
error_log('TWP FCM: Project ID or service account not configured');
return false;
}
@@ -57,47 +63,54 @@ class TWP_FCM {
}
/**
* Send notification to specific token
* Send notification to specific token via FCM HTTP v2 API
*/
private function send_to_token($token, $title, $body, $data = array(), $data_only = false) {
if ($data_only) {
$payload = array(
'to' => $token,
'data' => array_merge($data, array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
);
} else {
$notification = array(
$access_token = $this->get_access_token();
if (!$access_token) {
return array('success' => false, 'error' => 'auth_failed');
}
// FCM v2 requires all data values to be strings
$string_data = array();
foreach ($data as $key => $value) {
$string_data[$key] = is_string($value) ? $value : (string)$value;
}
$string_data['title'] = $title;
$string_data['body'] = $body;
$string_data['timestamp'] = (string)time();
// Build the v2 message payload
$message = array(
'token' => $token,
'data' => $string_data,
'android' => array(
'priority' => 'high',
),
);
if (!$data_only) {
$message['notification'] = array(
'title' => $title,
'body' => $body,
'sound' => 'default',
'priority' => 'high',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
);
$payload = array(
'to' => $token,
'notification' => $notification,
'data' => array_merge($data, array(
'title' => $title,
'body' => $body,
'timestamp' => time()
)),
'priority' => 'high'
$message['android']['notification'] = array(
'sound' => 'default',
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
);
}
$payload = array('message' => $message);
$url = sprintf($this->fcm_url_template, $this->project_id);
$headers = array(
'Authorization: key=' . $this->server_key,
'Authorization: Bearer ' . $access_token,
'Content-Type: application/json'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->fcm_url);
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -111,10 +124,14 @@ class TWP_FCM {
if ($http_code !== 200) {
error_log("TWP FCM: Failed to send notification. HTTP $http_code: $response");
// Check if token is invalid
$response_data = json_decode($response, true);
if (isset($response_data['results'][0]['error']) &&
in_array($response_data['results'][0]['error'], array('InvalidRegistration', 'NotRegistered'))) {
$error_code = isset($response_data['error']['details'][0]['errorCode'])
? $response_data['error']['details'][0]['errorCode'] : '';
$error_status = isset($response_data['error']['status'])
? $response_data['error']['status'] : '';
if (in_array($error_code, array('UNREGISTERED', 'INVALID_ARGUMENT')) ||
$error_status === 'NOT_FOUND') {
return array('success' => false, 'error' => 'invalid_token');
}
@@ -124,6 +141,107 @@ class TWP_FCM {
return array('success' => true);
}
/**
* Get OAuth2 access token from service account credentials.
* Caches the token in a transient until near expiry.
*/
private function get_access_token() {
$cached = get_transient('twp_fcm_access_token');
if ($cached) {
return $cached;
}
if (empty($this->service_account)) {
error_log('TWP FCM: Service account not configured');
return false;
}
$jwt = $this->create_jwt();
if (!$jwt) {
return false;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->service_account['token_uri']);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array(
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
)));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code !== 200) {
error_log("TWP FCM: Failed to get access token. HTTP $http_code: $response");
return false;
}
$token_data = json_decode($response, true);
$access_token = $token_data['access_token'];
$expires_in = isset($token_data['expires_in']) ? (int)$token_data['expires_in'] : 3600;
// Cache token for 5 minutes less than actual expiry
set_transient('twp_fcm_access_token', $access_token, max(60, $expires_in - 300));
return $access_token;
}
/**
* Create a signed JWT for the service account OAuth2 flow
*/
private function create_jwt() {
$sa = $this->service_account;
if (empty($sa['client_email']) || empty($sa['private_key']) || empty($sa['token_uri'])) {
error_log('TWP FCM: Service account JSON missing required fields');
return false;
}
$now = time();
$header = array('alg' => 'RS256', 'typ' => 'JWT');
$claims = array(
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => $sa['token_uri'],
'iat' => $now,
'exp' => $now + 3600,
);
$segments = array(
$this->base64url_encode(json_encode($header)),
$this->base64url_encode(json_encode($claims)),
);
$signing_input = implode('.', $segments);
$private_key = openssl_pkey_get_private($sa['private_key']);
if (!$private_key) {
error_log('TWP FCM: Failed to parse service account private key');
return false;
}
$signature = '';
if (!openssl_sign($signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256)) {
error_log('TWP FCM: Failed to sign JWT');
return false;
}
$segments[] = $this->base64url_encode($signature);
return implode('.', $segments);
}
/**
* Base64url encode (RFC 4648)
*/
private function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Get all active FCM tokens for a user
*/
@@ -218,7 +336,7 @@ class TWP_FCM {
$data = array(
'type' => 'test',
'test' => true
'test' => 'true'
);
return $this->send_notification($user_id, $title, $body, $data);