@voyant-travel/plugin-netopia 0.104.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +80 -0
- package/dist/checkout.d.ts +4 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +20 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +221 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/ipn.d.ts +42 -0
- package/dist/ipn.d.ts.map +1 -0
- package/dist/ipn.js +188 -0
- package/dist/notification-runtime.d.ts +13 -0
- package/dist/notification-runtime.d.ts.map +1 -0
- package/dist/notification-runtime.js +22 -0
- package/dist/plugin.d.ts +736 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +231 -0
- package/dist/service-callback.d.ts +8 -0
- package/dist/service-callback.d.ts.map +1 -0
- package/dist/service-callback.js +200 -0
- package/dist/service-collect.d.ts +9 -0
- package/dist/service-collect.d.ts.map +1 -0
- package/dist/service-collect.js +70 -0
- package/dist/service-shared.d.ts +63 -0
- package/dist/service-shared.d.ts.map +1 -0
- package/dist/service-shared.js +40 -0
- package/dist/service-start.d.ts +6 -0
- package/dist/service-start.d.ts.map +1 -0
- package/dist/service-start.js +82 -0
- package/dist/service.d.ts +13 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +12 -0
- package/dist/types.d.ts +259 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +666 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +139 -0
- package/package.json +73 -0
package/dist/ipn.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Netopia v2 IPN signature verification.
|
|
3
|
+
*
|
|
4
|
+
* Netopia v2 signs every IPN callback with a JWT carried in the
|
|
5
|
+
* `Verification-token` HTTP header (see the official SDKs, e.g.
|
|
6
|
+
* github.com/netopiapayments/go-sdk `VerifyIPN`):
|
|
7
|
+
*
|
|
8
|
+
* - signed with RSA (RS256/RS384/RS512) using NETOPIA's platform key;
|
|
9
|
+
* merchants receive the platform certificate (PEM) to verify against
|
|
10
|
+
* - `iss` must be `"NETOPIA Payments"`
|
|
11
|
+
* - `aud` must contain the merchant's POS signature
|
|
12
|
+
* - `sub` is the base64 (standard alphabet) SHA-512 digest of the raw
|
|
13
|
+
* request body, binding the token to this exact payload
|
|
14
|
+
*
|
|
15
|
+
* This module implements that verification using only Web Crypto, so it
|
|
16
|
+
* runs on Cloudflare Workers (no Node `crypto`). It accepts the key as
|
|
17
|
+
* either a `CERTIFICATE` PEM (the SubjectPublicKeyInfo is extracted with a
|
|
18
|
+
* minimal DER walk) or a `PUBLIC KEY` (SPKI) PEM.
|
|
19
|
+
*/
|
|
20
|
+
const JWT_HASHES = {
|
|
21
|
+
RS256: "SHA-256",
|
|
22
|
+
RS384: "SHA-384",
|
|
23
|
+
RS512: "SHA-512",
|
|
24
|
+
};
|
|
25
|
+
export async function verifyNetopiaIpnToken(input) {
|
|
26
|
+
const { token, rawBody, posSignature, publicKeyPem } = input;
|
|
27
|
+
if (!token)
|
|
28
|
+
return { ok: false, reason: "missing_verification_token" };
|
|
29
|
+
const parts = token.split(".");
|
|
30
|
+
if (parts.length !== 3)
|
|
31
|
+
return { ok: false, reason: "malformed_verification_token" };
|
|
32
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
33
|
+
let header;
|
|
34
|
+
let claims;
|
|
35
|
+
try {
|
|
36
|
+
header = JSON.parse(base64UrlDecodeToString(headerB64));
|
|
37
|
+
claims = JSON.parse(base64UrlDecodeToString(payloadB64));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { ok: false, reason: "malformed_verification_token" };
|
|
41
|
+
}
|
|
42
|
+
const alg = typeof header.alg === "string" ? header.alg.toUpperCase() : "";
|
|
43
|
+
const hash = JWT_HASHES[alg];
|
|
44
|
+
// Pinning to RSA algorithms also rejects `none` / HS* downgrade attempts.
|
|
45
|
+
if (!hash)
|
|
46
|
+
return { ok: false, reason: "unsupported_jwt_algorithm" };
|
|
47
|
+
let key;
|
|
48
|
+
try {
|
|
49
|
+
key = await importRsaVerifyKey(publicKeyPem, hash);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { ok: false, reason: "invalid_public_key" };
|
|
53
|
+
}
|
|
54
|
+
let signatureValid = false;
|
|
55
|
+
try {
|
|
56
|
+
signatureValid = await crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, key, toArrayBuffer(base64UrlDecodeToBytes(signatureB64)), new TextEncoder().encode(`${headerB64}.${payloadB64}`));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
signatureValid = false;
|
|
60
|
+
}
|
|
61
|
+
if (!signatureValid)
|
|
62
|
+
return { ok: false, reason: "invalid_signature" };
|
|
63
|
+
if (claims.iss !== "NETOPIA Payments")
|
|
64
|
+
return { ok: false, reason: "invalid_issuer" };
|
|
65
|
+
const aud = claims.aud;
|
|
66
|
+
const audiences = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud.filter(isString) : [];
|
|
67
|
+
if (!audiences.some((value) => timingSafeStringEqual(value, posSignature))) {
|
|
68
|
+
return { ok: false, reason: "invalid_audience" };
|
|
69
|
+
}
|
|
70
|
+
// Standard temporal claims, when present (60s clock-skew leeway).
|
|
71
|
+
const nowSeconds = Date.now() / 1000;
|
|
72
|
+
if (typeof claims.exp === "number" && nowSeconds > claims.exp + 60) {
|
|
73
|
+
return { ok: false, reason: "token_expired" };
|
|
74
|
+
}
|
|
75
|
+
if (typeof claims.nbf === "number" && nowSeconds < claims.nbf - 60) {
|
|
76
|
+
return { ok: false, reason: "token_not_yet_valid" };
|
|
77
|
+
}
|
|
78
|
+
// `sub` binds the signed token to this exact body: base64(SHA-512(body)).
|
|
79
|
+
const digest = await crypto.subtle.digest("SHA-512", new TextEncoder().encode(rawBody));
|
|
80
|
+
const bodyHash = bytesToBase64(new Uint8Array(digest));
|
|
81
|
+
if (!isString(claims.sub) || !timingSafeStringEqual(claims.sub, bodyHash)) {
|
|
82
|
+
return { ok: false, reason: "payload_hash_mismatch" };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true };
|
|
85
|
+
}
|
|
86
|
+
function toArrayBuffer(bytes) {
|
|
87
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
88
|
+
}
|
|
89
|
+
function isString(value) {
|
|
90
|
+
return typeof value === "string";
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Constant-time string comparison (over UTF-16 code units). Length still
|
|
94
|
+
* leaks, which is fine here — lengths of digests/POS signatures are public.
|
|
95
|
+
*/
|
|
96
|
+
export function timingSafeStringEqual(a, b) {
|
|
97
|
+
const length = Math.max(a.length, b.length);
|
|
98
|
+
let diff = a.length ^ b.length;
|
|
99
|
+
for (let i = 0; i < length; i++) {
|
|
100
|
+
diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
101
|
+
}
|
|
102
|
+
return diff === 0;
|
|
103
|
+
}
|
|
104
|
+
async function importRsaVerifyKey(pem, hash) {
|
|
105
|
+
const der = pemToDer(pem);
|
|
106
|
+
const spki = der.label === "CERTIFICATE" ? extractSpkiFromCertificate(der.bytes) : der.bytes;
|
|
107
|
+
return crypto.subtle.importKey("spki", spki.buffer.slice(spki.byteOffset, spki.byteOffset + spki.byteLength), { name: "RSASSA-PKCS1-v1_5", hash }, false, ["verify"]);
|
|
108
|
+
}
|
|
109
|
+
function pemToDer(pem) {
|
|
110
|
+
const match = pem.match(/-----BEGIN ([A-Z0-9 ]+)-----([\s\S]+?)-----END \1-----/);
|
|
111
|
+
if (!match)
|
|
112
|
+
throw new Error("Invalid PEM");
|
|
113
|
+
const label = match[1];
|
|
114
|
+
if (label !== "CERTIFICATE" && label !== "PUBLIC KEY") {
|
|
115
|
+
throw new Error(`Unsupported PEM label: ${label}`);
|
|
116
|
+
}
|
|
117
|
+
const body = match[2].replace(/[\s\r\n]/g, "");
|
|
118
|
+
return { label, bytes: base64DecodeToBytes(body) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Extracts the SubjectPublicKeyInfo from an X.509 certificate with a minimal
|
|
122
|
+
* DER walk (Web Crypto has no certificate parser):
|
|
123
|
+
*
|
|
124
|
+
* Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
|
|
125
|
+
* TBSCertificate ::= SEQUENCE { [0] version OPTIONAL, serialNumber, signature,
|
|
126
|
+
* issuer, validity, subject, subjectPublicKeyInfo, ... }
|
|
127
|
+
*/
|
|
128
|
+
function extractSpkiFromCertificate(der) {
|
|
129
|
+
const certificate = readDerElement(der, 0);
|
|
130
|
+
const tbs = readDerElement(der, certificate.contentStart);
|
|
131
|
+
let offset = tbs.contentStart;
|
|
132
|
+
// Optional explicit version tag: context-specific [0] constructed (0xa0).
|
|
133
|
+
if (der[offset] === 0xa0) {
|
|
134
|
+
offset = readDerElement(der, offset).end;
|
|
135
|
+
}
|
|
136
|
+
// serialNumber, signature (algorithm), issuer, validity, subject.
|
|
137
|
+
for (let i = 0; i < 5; i++) {
|
|
138
|
+
offset = readDerElement(der, offset).end;
|
|
139
|
+
}
|
|
140
|
+
const spki = readDerElement(der, offset);
|
|
141
|
+
if (der[offset] !== 0x30)
|
|
142
|
+
throw new Error("Malformed certificate: SPKI not a SEQUENCE");
|
|
143
|
+
return der.subarray(offset, spki.end);
|
|
144
|
+
}
|
|
145
|
+
function readDerElement(der, offset) {
|
|
146
|
+
if (offset + 2 > der.length)
|
|
147
|
+
throw new Error("Malformed DER: truncated element");
|
|
148
|
+
let cursor = offset + 1; // skip tag
|
|
149
|
+
let length = der[cursor];
|
|
150
|
+
cursor += 1;
|
|
151
|
+
if (length & 0x80) {
|
|
152
|
+
const lengthBytes = length & 0x7f;
|
|
153
|
+
if (lengthBytes === 0 || lengthBytes > 4 || cursor + lengthBytes > der.length) {
|
|
154
|
+
throw new Error("Malformed DER: bad length");
|
|
155
|
+
}
|
|
156
|
+
length = 0;
|
|
157
|
+
for (let i = 0; i < lengthBytes; i++) {
|
|
158
|
+
length = length * 256 + der[cursor + i];
|
|
159
|
+
}
|
|
160
|
+
cursor += lengthBytes;
|
|
161
|
+
}
|
|
162
|
+
const end = cursor + length;
|
|
163
|
+
if (end > der.length)
|
|
164
|
+
throw new Error("Malformed DER: content overruns buffer");
|
|
165
|
+
return { contentStart: cursor, end };
|
|
166
|
+
}
|
|
167
|
+
function base64UrlDecodeToString(value) {
|
|
168
|
+
return new TextDecoder().decode(base64UrlDecodeToBytes(value));
|
|
169
|
+
}
|
|
170
|
+
function base64UrlDecodeToBytes(value) {
|
|
171
|
+
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
172
|
+
return base64DecodeToBytes(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
|
|
173
|
+
}
|
|
174
|
+
function base64DecodeToBytes(base64) {
|
|
175
|
+
const binary = atob(base64);
|
|
176
|
+
const bytes = new Uint8Array(binary.length);
|
|
177
|
+
for (let i = 0; i < binary.length; i++) {
|
|
178
|
+
bytes[i] = binary.charCodeAt(i);
|
|
179
|
+
}
|
|
180
|
+
return bytes;
|
|
181
|
+
}
|
|
182
|
+
function bytesToBase64(bytes) {
|
|
183
|
+
let binary = "";
|
|
184
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
185
|
+
binary += String.fromCharCode(bytes[i]);
|
|
186
|
+
}
|
|
187
|
+
return btoa(binary);
|
|
188
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type NotificationProvider, type NotificationService } from "@voyant-travel/notifications";
|
|
2
|
+
import type { NetopiaRuntimeOptions } from "./types.js";
|
|
3
|
+
export type NetopiaNotificationRuntime = {
|
|
4
|
+
dispatcher: NotificationService;
|
|
5
|
+
};
|
|
6
|
+
export type NetopiaNotificationRuntimeOptions = Pick<NetopiaRuntimeOptions, "resolveNotificationProviders"> & {
|
|
7
|
+
notificationProviders?: ReadonlyArray<NotificationProvider>;
|
|
8
|
+
};
|
|
9
|
+
export declare class NetopiaNotificationRuntimeError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export declare function buildNetopiaNotificationRuntime(bindings: Record<string, unknown> | undefined, runtimeOptions?: NetopiaNotificationRuntimeOptions, dispatcherOverride?: NotificationService): NetopiaNotificationRuntime;
|
|
13
|
+
//# sourceMappingURL=notification-runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification-runtime.d.ts","sourceRoot":"","sources":["../src/notification-runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACzB,MAAM,8BAA8B,CAAA;AAErC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAEvD,MAAM,MAAM,0BAA0B,GAAG;IACvC,UAAU,EAAE,mBAAmB,CAAA;CAChC,CAAA;AAED,MAAM,MAAM,iCAAiC,GAAG,IAAI,CAClD,qBAAqB,EACrB,8BAA8B,CAC/B,GAAG;IACF,qBAAqB,CAAC,EAAE,aAAa,CAAC,oBAAoB,CAAC,CAAA;CAC5D,CAAA;AAED,qBAAa,+BAAgC,SAAQ,KAAK;gBAC5C,OAAO,EAAE,MAAM;CAI5B;AAED,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC7C,cAAc,GAAE,iCAAsC,EACtD,kBAAkB,CAAC,EAAE,mBAAmB,GACvC,0BAA0B,CAoB5B"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createNotificationService, } from "@voyant-travel/notifications";
|
|
2
|
+
export class NetopiaNotificationRuntimeError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "NetopiaNotificationRuntimeError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function buildNetopiaNotificationRuntime(bindings, runtimeOptions = {}, dispatcherOverride) {
|
|
9
|
+
if (dispatcherOverride) {
|
|
10
|
+
return {
|
|
11
|
+
dispatcher: dispatcherOverride,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const providers = runtimeOptions.resolveNotificationProviders?.(bindings ?? {}) ??
|
|
15
|
+
runtimeOptions.notificationProviders;
|
|
16
|
+
if (!providers) {
|
|
17
|
+
throw new NetopiaNotificationRuntimeError("Netopia plugin requires `resolveNotificationProviders` or `notificationProviders` — there are no default providers.");
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
dispatcher: createNotificationService(providers),
|
|
21
|
+
};
|
|
22
|
+
}
|