diff --git a/functions/handlers.js b/functions/handlers.js index adf4677..9ae0a0b 100644 --- a/functions/handlers.js +++ b/functions/handlers.js @@ -5,6 +5,9 @@ const { getMessaging } = require('firebase-admin/messaging'); const { FirestoreRateLimiter, ValkeyRateLimiter } = require('./rate-limiter'); const MAX_NOTIFICATIONS_PER_DAY = parseInt(process.env.MAX_NOTIFICATIONS_PER_DAY || '500'); +// Widget pushes get a separate, lower daily cap: Apple's WidgetKit push budget is +// small, so a widget token needs far fewer sends than a normal device. +const MAX_WIDGET_PUSHES_PER_DAY = parseInt(process.env.MAX_WIDGET_PUSHES_PER_DAY || '100'); const REGION = (process.env.REGION || 'us-central1').toLowerCase(); const usingCloudFunctions = process.env.FUNCTION_TARGET !== undefined; @@ -13,20 +16,25 @@ const messaging = getMessaging(); const logging = new Logging(); const debug = process.env.DEBUG === 'true'; -// Use Valkey rate limiter if Valkey config is available, otherwise use Firestore -let rateLimiter; +// Use Valkey rate limiter if Valkey config is available, otherwise use Firestore. const useValkey = process.env.VALKEY_HOST && process.env.VALKEY_PORT; -if (useValkey) { - rateLimiter = new ValkeyRateLimiter( - MAX_NOTIFICATIONS_PER_DAY, - debug, - process.env.VALKEY_HOST, - parseInt(process.env.VALKEY_PORT, 10), - ); -} else { - rateLimiter = new FirestoreRateLimiter(MAX_NOTIFICATIONS_PER_DAY, debug); +function buildRateLimiter(maxPerDay) { + if (useValkey) { + return new ValkeyRateLimiter( + maxPerDay, + debug, + process.env.VALKEY_HOST, + parseInt(process.env.VALKEY_PORT, 10), + ); + } + return new FirestoreRateLimiter(maxPerDay, debug); } +const rateLimiter = buildRateLimiter(MAX_NOTIFICATIONS_PER_DAY); +// Separate instance so widgets have their own daily cap; widget tokens are a +// different key namespace from FCM tokens, so their counters never collide. +const widgetRateLimiter = buildRateLimiter(MAX_WIDGET_PUSHES_PER_DAY); + async function handleCheckRateLimits(req, res) { const { push_token: token } = req.body; if (!token) { @@ -348,3 +356,4 @@ function buildLogMetadata(req) { exports.handleRequest = handleRequest; exports.handleCheckRateLimits = handleCheckRateLimits; +exports.widgetRateLimiter = widgetRateLimiter; diff --git a/functions/index.js b/functions/index.js index f9af52a..08638ea 100644 --- a/functions/index.js +++ b/functions/index.js @@ -8,6 +8,7 @@ initializeApp(); const android = require('./android'); const legacy = require('./legacy'); +const widgetPush = require('./widget-push'); const region = (functions.config().app && functions.config().app.region) || 'us-central1'; const regionalFunctions = functions.region(region).runWith({ timeoutSeconds: 10 }); @@ -22,9 +23,17 @@ exports.androidV1 = regionalFunctions.https.onRequest(async (req, res) => handleRequest(req, res, android.createPayload), ); -exports.sendPushNotification = regionalFunctions.https.onRequest(async (req, res) => - handleRequest(req, res, legacy.createPayload), -); +exports.sendPushNotification = functions + .region(region) + .runWith({ timeoutSeconds: 10, secrets: widgetPush.SECRETS }) + .https.onRequest(async (req, res) => { + // Widget push_subscription payloads target a WidgetKit push token, which FCM + // can't route — send those straight to APNs instead of through messaging.send. + if (req.body && req.body.push_subscription) { + return widgetPush.sendWidgetPush(req, res); + } + return handleRequest(req, res, legacy.createPayload); + }); exports.checkRateLimits = regionalFunctions.https.onRequest(async (req, res) => handleCheckRateLimits(req, res), diff --git a/functions/test/widget-push.test.js b/functions/test/widget-push.test.js new file mode 100644 index 0000000..8d0f059 --- /dev/null +++ b/functions/test/widget-push.test.js @@ -0,0 +1,235 @@ +'use strict'; + +const crypto = require('crypto'); +const { createMockRequest, createMockResponse } = require('./utils/mock-factories'); + +jest.mock('node:http2', () => ({ connect: jest.fn() })); +const http2 = require('node:http2'); + +const mockRateLimiter = { + recordAttempt: jest.fn(), + recordSuccess: jest.fn(), + recordError: jest.fn(), +}; +jest.mock('../handlers', () => ({ widgetRateLimiter: mockRateLimiter })); + +const widgetPush = require('../widget-push'); + +const WIDGET_TOKEN = '80f7d67347204c7dda85d331a95ec31c1e3c62b9173836ada8ed9abf'; + +// A real P-256 key so ES256 signing succeeds; its value is irrelevant to the mock. +const TEST_P8 = crypto + .generateKeyPairSync('ec', { namedCurve: 'P-256' }) + .privateKey.export({ type: 'pkcs8', format: 'pem' }); + +// Makes http2.connect return a client whose request replays the given APNs +// responses in order (one per connect call, so we can exercise the fallback). +function mockApns(responses, onRequest) { + let index = 0; + http2.connect.mockImplementation(() => { + const response = responses[Math.min(index, responses.length - 1)]; + index += 1; + const handlers = {}; + const request = { + on: jest.fn((event, cb) => { + handlers[event] = cb; + return request; + }), + setEncoding: jest.fn(), + end: jest.fn(() => { + process.nextTick(() => { + handlers.response?.({ ':status': response.status, 'apns-id': response.apnsId }); + if (response.body) handlers.data?.(response.body); + handlers.end?.(); + }); + }), + }; + return { + on: jest.fn(), + request: jest.fn((headers) => { + onRequest?.(headers); + return request; + }), + close: jest.fn(), + }; + }); +} + +function widgetRequest(overrides = {}) { + return createMockRequest({ + body: { + push_subscription: { subscription_id: 'ios-widget-sensors', target: 'sensors' }, + push_token: WIDGET_TOKEN, + registration_info: { app_id: 'io.test.HomeAssistant' }, + ...overrides, + }, + }); +} + +describe('widget-push', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.APNS_KEY_P8 = TEST_P8; + process.env.APNS_KEY_ID = 'KEY1234567'; + process.env.APNS_TEAM_ID = 'TEAM123456'; + mockRateLimiter.recordAttempt.mockResolvedValue({ isRateLimited: false, rateLimits: {} }); + mockRateLimiter.recordSuccess.mockResolvedValue({}); + mockRateLimiter.recordError.mockResolvedValue({}); + }); + + it('returns 201 and echoes the apns-id on a successful send', async () => { + mockApns([{ status: 200, apnsId: 'apns-success' }]); + const res = createMockResponse(); + + await widgetPush.sendWidgetPush(widgetRequest(), res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + target: WIDGET_TOKEN, + messageId: 'apns-success', + pushType: 'widgets', + }), + ); + }); + + it('rate-limits per token and returns 429 without reaching APNs', async () => { + mockRateLimiter.recordAttempt.mockResolvedValueOnce({ + isRateLimited: true, + rateLimits: { successful: 500 }, + }); + const res = createMockResponse(); + + await widgetPush.sendWidgetPush(widgetRequest(), res); + + expect(res.status).toHaveBeenCalledWith(429); + expect(http2.connect).not.toHaveBeenCalled(); + }); + + it('records the attempt outcome with the rate limiter on success', async () => { + mockApns([{ status: 200, apnsId: 'x' }]); + + await widgetPush.sendWidgetPush(widgetRequest(), createMockResponse()); + + expect(mockRateLimiter.recordAttempt).toHaveBeenCalledWith(WIDGET_TOKEN); + expect(mockRateLimiter.recordSuccess).toHaveBeenCalledWith(WIDGET_TOKEN); + }); + + it('sends the widgets push type, widget topic and device path', async () => { + let headers; + mockApns([{ status: 200, apnsId: 'x' }], (h) => { + headers = h; + }); + + await widgetPush.sendWidgetPush(widgetRequest(), createMockResponse()); + + expect(headers['apns-push-type']).toBe('widgets'); + expect(headers['apns-topic']).toBe('io.test.HomeAssistant.push-type.widgets'); + expect(headers[':path']).toBe(`/3/device/${WIDGET_TOKEN}`); + expect(headers.authorization).toMatch(/^bearer /); + }); + + it('falls back to the sandbox host on BadDeviceToken', async () => { + mockApns([ + { status: 400, body: '{"reason":"BadDeviceToken"}' }, + { status: 200, apnsId: 'sandbox-ok' }, + ]); + const res = createMockResponse(); + + await widgetPush.sendWidgetPush(widgetRequest(), res); + + expect(http2.connect).toHaveBeenCalledTimes(2); + expect(http2.connect).toHaveBeenNthCalledWith(1, 'https://api.push.apple.com'); + expect(http2.connect).toHaveBeenNthCalledWith(2, 'https://api.sandbox.push.apple.com'); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it('returns 403 when no token is sent', async () => { + const res = createMockResponse(); + await widgetPush.sendWidgetPush(widgetRequest({ push_token: null }), res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('returns 400 when registration_info.app_id is missing', async () => { + const res = createMockResponse(); + await widgetPush.sendWidgetPush(widgetRequest({ registration_info: {} }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects a non-hex push token without reaching APNs', async () => { + const res = createMockResponse(); + await widgetPush.sendWidgetPush(widgetRequest({ push_token: `${WIDGET_TOKEN}/../evil` }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(http2.connect).not.toHaveBeenCalled(); + }); + + it('rejects an app id with illegal characters without reaching APNs', async () => { + const res = createMockResponse(); + await widgetPush.sendWidgetPush( + widgetRequest({ registration_info: { app_id: 'io.test.HomeAssistant\r\nx-evil: 1' } }), + res, + ); + expect(res.status).toHaveBeenCalledWith(400); + expect(http2.connect).not.toHaveBeenCalled(); + }); + + it('closes the HTTP/2 client and rejects on a session error', async () => { + const request = { on: jest.fn().mockReturnThis(), setEncoding: jest.fn(), end: jest.fn() }; + const client = { on: jest.fn(), request: jest.fn(() => request), close: jest.fn() }; + client.on.mockImplementation((event, cb) => { + if (event === 'error') process.nextTick(() => cb(new Error('boom'))); + return client; + }); + http2.connect.mockReturnValue(client); + const res = createMockResponse(); + + await widgetPush.sendWidgetPush(widgetRequest(), res); + + expect(client.close).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(502); + }); + + it('closes the HTTP/2 client and rejects on a stream error', async () => { + const handlers = {}; + const request = { + on: jest.fn((event, cb) => { + handlers[event] = cb; + return request; + }), + setEncoding: jest.fn(), + end: jest.fn(() => process.nextTick(() => handlers.error?.(new Error('stream boom')))), + }; + const client = { on: jest.fn(), request: jest.fn(() => request), close: jest.fn() }; + http2.connect.mockReturnValue(client); + const res = createMockResponse(); + + await widgetPush.sendWidgetPush(widgetRequest(), res); + + expect(client.close).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(502); + }); + + it('returns 500 when the rate-limit check throws', async () => { + mockRateLimiter.recordAttempt.mockRejectedValueOnce(new Error('firestore down')); + const res = createMockResponse(); + + await widgetPush.sendWidgetPush(widgetRequest(), res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(http2.connect).not.toHaveBeenCalled(); + }); + + it('returns 500 when APNs credentials are not configured', async () => { + delete process.env.APNS_KEY_P8; + const res = createMockResponse(); + await widgetPush.sendWidgetPush(widgetRequest(), res); + expect(res.status).toHaveBeenCalledWith(500); + }); + + it('propagates a non-fallback APNs rejection status', async () => { + mockApns([{ status: 410, body: '{"reason":"Unregistered"}' }]); + const res = createMockResponse(); + await widgetPush.sendWidgetPush(widgetRequest(), res); + expect(res.status).toHaveBeenCalledWith(410); + }); +}); diff --git a/functions/widget-push.js b/functions/widget-push.js new file mode 100644 index 0000000..ee5458c --- /dev/null +++ b/functions/widget-push.js @@ -0,0 +1,221 @@ +'use strict'; + +// Direct-to-APNs sender for iOS 26 WidgetKit push subscriptions. +// +// Core registers a widget's push token + tracked entities and, when one of those +// entities changes, POSTs a { push_subscription, push_token, registration_info } +// payload to this relay (the same push_url as normal notifications). +// +// The push_token here is a WidgetKit widget push token — a raw APNs token that +// FCM has no mapping for, so messaging.send() can't deliver it. This module +// bypasses the FCM SDK for this one path and talks to APNs directly over HTTP/2, +// authenticating with the APNs auth key (.p8) provided as a Functions secret. + +const http2 = require('node:http2'); +const crypto = require('node:crypto'); + +// Xcode/dev builds get sandbox tokens; App Store / TestFlight get production ones. +// The token itself doesn't say which, so we try production and fall back to +// sandbox when APNs replies BadDeviceToken (wrong environment). +const APNS_HOST_PRODUCTION = 'api.push.apple.com'; +const APNS_HOST_SANDBOX = 'api.sandbox.push.apple.com'; + +// iOS 26 WidgetKit push: apns-push-type is "widgets" and the topic is the app's +// bundle id with a ".push-type.widgets" suffix. +const APNS_PUSH_TYPE = 'widgets'; +const WIDGET_TOPIC_SUFFIX = '.push-type.widgets'; + +// Provider JWTs are valid up to 1 hour and APNs rate-limits token creation, so we +// sign once and reuse well within the window. +const JWT_TTL_MS = 40 * 60 * 1000; + +// The token and app id go straight into the APNs :path and apns-topic, so reject +// anything malformed before building the request: device tokens are hex, bundle +// ids are reverse-DNS labels (letters, digits, dots, hyphens). +const HEX_TOKEN = /^[0-9a-f]+$/i; +const BUNDLE_ID = /^[A-Za-z0-9.-]+$/; + +// Secret Manager names, injected as env vars at runtime via defineSecret/runWith. +const SECRETS = ['APNS_KEY_P8', 'APNS_KEY_ID', 'APNS_TEAM_ID']; + +let cachedJwt = null; +let cachedJwtAt = 0; + +function base64url(value) { + return Buffer.from(value) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +// Builds an APNs provider-auth JWT signed with the .p8 key (ES256). The private +// key never leaves the function; only this signature travels to Apple. +function signProviderToken() { + const header = base64url(JSON.stringify({ alg: 'ES256', kid: process.env.APNS_KEY_ID })); + const payload = base64url( + JSON.stringify({ iss: process.env.APNS_TEAM_ID, iat: Math.floor(Date.now() / 1000) }), + ); + const signingInput = `${header}.${payload}`; + + // dsaEncoding 'ieee-p1363' yields the raw R||S signature that JWS/ES256 expects + // (Node's default is DER, which APNs would reject). + const signature = crypto + .sign('sha256', Buffer.from(signingInput), { + key: process.env.APNS_KEY_P8, + dsaEncoding: 'ieee-p1363', + }) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + return `${signingInput}.${signature}`; +} + +function providerToken() { + const now = Date.now(); + if (!cachedJwt || now - cachedJwtAt > JWT_TTL_MS) { + cachedJwt = signProviderToken(); + cachedJwtAt = now; + } + return cachedJwt; +} + +// Sends one request to APNs over HTTP/2 and resolves with { status, apnsId, body }. +function postToApns(host, deviceToken, headers, body) { + return new Promise((resolve, reject) => { + const client = http2.connect(`https://${host}`); + client.on('error', (err) => { + client.close(); + reject(err); + }); + + const request = client.request({ + ':method': 'POST', + ':path': `/3/device/${deviceToken}`, + ...headers, + }); + + let status; + let apnsId; + let data = ''; + request.on('response', (responseHeaders) => { + status = responseHeaders[':status']; + apnsId = responseHeaders['apns-id']; + }); + request.setEncoding('utf8'); + request.on('data', (chunk) => { + data += chunk; + }); + request.on('end', () => { + client.close(); + resolve({ status, apnsId, body: data }); + }); + request.on('error', (err) => { + client.close(); + reject(err); + }); + + request.end(body); + }); +} + +// Records the send outcome for a token, best-effort — the push has already been +// sent, so a rate-limiter write failure here must not change the response. +async function recordOutcome(rateLimiter, token, succeeded) { + try { + await (succeeded ? rateLimiter.recordSuccess(token) : rateLimiter.recordError(token)); + } catch { + // Accounting is best-effort and must not affect delivery. + } +} + +// Handles a widget push_subscription payload by sending a silent WidgetKit +// refresh straight to APNs. +async function sendWidgetPush(req, res) { + const token = req.body.push_token; + const appId = req.body.registration_info && req.body.registration_info.app_id; + + if (!token) { + return res.status(403).send({ errorMessage: 'You did not send a token!' }); + } + if (!appId) { + return res.status(400).send({ errorMessage: 'Missing registration_info.app_id' }); + } + if (!HEX_TOKEN.test(token)) { + return res.status(400).send({ errorMessage: 'Invalid push token format' }); + } + if (!BUNDLE_ID.test(appId)) { + return res.status(400).send({ errorMessage: 'Invalid app id format' }); + } + if (!process.env.APNS_KEY_P8 || !process.env.APNS_KEY_ID || !process.env.APNS_TEAM_ID) { + return res.status(500).send({ errorMessage: 'APNs credentials are not configured' }); + } + + // Widget pushes are rate-limited per token (with their own daily cap) so a + // widget push token can't be used to hammer APNs (cost, throttling, device + // battery drain). Required lazily so it uses the instance index.js initialises + // once the environment is set. + const { widgetRateLimiter: rateLimiter } = require('./handlers'); + let attempt; + try { + attempt = await rateLimiter.recordAttempt(token); + } catch (err) { + return res.status(500).send({ errorMessage: `Rate limit check failed: ${err.message}` }); + } + if (attempt.isRateLimited) { + return res.status(429).send({ + errorType: 'RateLimited', + message: + 'The given target has reached the maximum number of notifications allowed per day. Please try again later.', + target: token, + rateLimits: attempt.rateLimits, + }); + } + + const headers = { + 'apns-topic': `${appId}${WIDGET_TOPIC_SUFFIX}`, + 'apns-push-type': APNS_PUSH_TYPE, + 'apns-priority': '5', + }; + // Silent and data-free: WidgetKit just reloads the widget's timeline. + const body = JSON.stringify({ aps: { 'content-changed': true } }); + + let result; + try { + result = await postToApns( + APNS_HOST_PRODUCTION, + token, + { ...headers, authorization: `bearer ${providerToken()}` }, + body, + ); + + if (result.status === 400 && result.body.includes('BadDeviceToken')) { + result = await postToApns( + APNS_HOST_SANDBOX, + token, + { ...headers, authorization: `bearer ${providerToken()}` }, + body, + ); + } + } catch (err) { + return res.status(502).send({ errorMessage: `Failed to reach APNs: ${err.message}` }); + } + + if (result.status === 200) { + await recordOutcome(rateLimiter, token, true); + return res.status(201).send({ + target: token, + messageId: result.apnsId, + pushType: APNS_PUSH_TYPE, + }); + } + + await recordOutcome(rateLimiter, token, false); + return res.status(result.status || 502).send({ + errorMessage: `APNs rejected the widget push: ${result.body || 'unknown error'}`, + }); +} + +module.exports = { sendWidgetPush, SECRETS };