Skip to content

Commit 709c833

Browse files
authored
feat: [WPN-14] Derive methods to build headers for VAPID using JWT (#26)
1 parent eee5454 commit 709c833

2 files changed

Lines changed: 96 additions & 3 deletions

File tree

packages/builder/lib/request.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,49 @@
11
import { crypto } from './crypto.js';
22
import { encryptPayload } from './payload.js';
33
import type { BuilderOptions, PushOptions } from './types.js';
4+
import { vapidHeaders } from './vapid.js';
45

56
/**
67
* Builds an HTTP request body and headers for sending a push notification.
78
*
9+
* This function constructs the necessary components for a push notification request,
10+
* including the payload, headers, and any required cryptographic operations.
11+
*
812
* @param {BuilderOptions} options - The options for building the push notification request.
913
* @param {JsonWebKey | string} options.privateJWK - The private JSON Web Key (JWK) used for signing.
10-
* @param {PushMessage} options.message - The message to be sent in the push notification with user defined options.
14+
* @param {PushMessage} options.message - The message to be sent in the push notification, including user-defined options.
1115
* @param {PushSubscription} options.subscription - The subscription details for the push notification.
16+
* @returns {Promise<{ endpoint: string, body: ArrayBuffer, headers: Record<string, string> | Headers }>} A promise that resolves to an object containing the endpoint, encrypted body, and headers for the push notification.
1217
*
13-
* @throws {Error} Throws an error if the privateJWK is invalid or if the request fails.
18+
* @throws {Error} Throws an error if the privateJWK is invalid, if the request fails, or if the payload encryption fails.
1419
*/
1520
export async function buildPushHTTPRequest({
1621
privateJWK,
1722
message,
1823
subscription,
19-
}: BuilderOptions) {
24+
}: BuilderOptions): Promise<{
25+
endpoint: string;
26+
body: ArrayBuffer;
27+
headers: Record<string, string> | Headers;
28+
}> {
29+
// Parse the private JWK if it's a string
2030
const jwk: JsonWebKey =
2131
typeof privateJWK === 'string' ? JSON.parse(privateJWK) : privateJWK;
2232

33+
// Determine the time-to-live (TTL) for the push notification
2334
const ttl =
2435
message.options?.ttl && message.options.ttl > 0
2536
? message.options.ttl
2637
: 24 * 60 * 60; // Default to 24 hours
38+
39+
// Create the JWT payload
2740
const jwt = {
2841
aud: new URL(subscription.endpoint).origin,
2942
exp: Math.floor(Date.now() / 1000) + ttl,
3043
sub: message.adminContact,
3144
};
3245

46+
// Construct the options for the push notification
3347
const options: PushOptions = {
3448
jwk,
3549
jwt,
@@ -43,17 +57,32 @@ export async function buildPushHTTPRequest({
4357
}),
4458
};
4559

60+
// Generate a random salt for encryption
4661
const salt = crypto.getRandomValues(new Uint8Array(16));
4762

63+
// Generate local keys for encryption
4864
const localKeys = await crypto.subtle.generateKey(
4965
{ name: 'ECDH', namedCurve: 'P-256' },
5066
true,
5167
['deriveBits'],
5268
);
69+
70+
// Encrypt the payload for the push notification
5371
const body = await encryptPayload(
5472
localKeys,
5573
salt,
5674
options.payload,
5775
subscription,
5876
);
77+
78+
// Construct the VAPID headers for the push notification request
79+
const headers = await vapidHeaders(
80+
options,
81+
body.byteLength,
82+
salt,
83+
localKeys.publicKey,
84+
);
85+
86+
// Return the constructed request components
87+
return { endpoint: subscription.endpoint, body, headers };
5988
}

packages/builder/lib/vapid.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { base64UrlEncode } from './base64.js';
2+
import { crypto } from './crypto.js';
3+
import { createJwt } from './jwt.js';
4+
import type { PushOptions } from './types.js';
5+
import { getPublicKeyFromJwk } from './utils.js';
6+
7+
/**
8+
* Constructs the VAPID headers for a push notification request.
9+
*
10+
* This function generates the necessary headers for sending a push notification
11+
* using the VAPID protocol, including authentication and encryption information.
12+
*
13+
* @param {PushOptions} options - The options for the push notification, including the JSON Web Key (JWK) and JWT data.
14+
* @param {number} payloadLength - The length of the payload being sent in the push notification.
15+
* @param {Uint8Array} salt - A salt value used in the encryption process.
16+
* @param {CryptoKey} localPublicKey - The local public key used for encryption.
17+
* @returns {Promise<Record<string, string> | Headers>} A promise that resolves to an object containing the VAPID headers.
18+
*
19+
* @throws {Error} Throws an error if the JWT creation fails or if key export fails.
20+
*/
21+
export const vapidHeaders = async (
22+
options: PushOptions,
23+
payloadLength: number,
24+
salt: Uint8Array,
25+
localPublicKey: CryptoKey,
26+
) => {
27+
// Export the local public key to a raw format and encode it in Base64 URL format
28+
const localPublicKeyBase64 = await crypto.subtle
29+
.exportKey('raw', localPublicKey)
30+
.then((bytes) => base64UrlEncode(bytes));
31+
32+
// Get the server public key from the JWK
33+
const serverPublicKey = getPublicKeyFromJwk(options.jwk);
34+
35+
// Create the JWT for authentication
36+
const jwt = await createJwt(options.jwk, options.jwt);
37+
38+
// Construct the header values for the VAPID request
39+
const headerValues: Record<string, string> = {
40+
Encryption: `salt=${base64UrlEncode(salt)}`,
41+
'Crypto-Key': `dh=${localPublicKeyBase64}`,
42+
'Content-Length': payloadLength.toString(),
43+
'Content-Type': 'application/octet-stream',
44+
'Content-Encoding': 'aesgcm',
45+
Authorization: `vapid t=${jwt}, k=${serverPublicKey}`,
46+
};
47+
48+
let headers: Record<string, string> | Headers;
49+
50+
// Add optional headers if they are defined
51+
if (options.ttl !== undefined) headerValues.TTL = options.ttl.toString();
52+
if (options.topic !== undefined) headerValues.Topic = options.topic;
53+
if (options.urgency !== undefined) headerValues.Urgency = options.urgency;
54+
55+
// Create Headers object if available (for browser or Node.js 18+)
56+
if (typeof Headers !== 'undefined') {
57+
headers = new Headers(headerValues);
58+
} else {
59+
// Fallback for Node.js < 18 without polyfill
60+
headers = headerValues;
61+
}
62+
63+
return headers; // Return the constructed headers
64+
};

0 commit comments

Comments
 (0)