Compare commits

...

2 Commits

Author SHA1 Message Date
Jose Olarte III
8e502a2335 feat(notifications): bind device registrations to authenticated user DID
Scope register and refresh to verified JWT identity (req.did). Persist
devices under userId::deviceId, reject client-supplied userId, and dedupe
FCM tokens per user.
2026-05-19 19:02:42 +08:00
Jose Olarte III
4bf57d26fd Add Bearer JWT auth middleware for notification routes
Mirror image-api’s DID JWT verification (src/vc + requireAuth) so
/notifications/* require a valid Authorization header while /health
stays public. Attach req.did, req.jwt, and req.auth for downstream use.
2026-05-19 18:23:41 +08:00
11 changed files with 889 additions and 20 deletions

327
package-lock.json generated
View File

@@ -8,6 +8,11 @@
"name": "notification-wakeup-service",
"version": "0.1.0",
"dependencies": {
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"cbor-x": "^1.5.9",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"express": "^5.1.0",
"firebase-admin": "^13.9.0"
},
@@ -18,6 +23,84 @@
"typescript": "^5.7.2"
}
},
"node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz",
"integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-darwin-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz",
"integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz",
"integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz",
"integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz",
"integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-win32-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz",
"integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -765,6 +848,48 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@multiformats/base-x": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@multiformats/base-x/-/base-x-4.0.1.tgz",
"integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.4.1.tgz",
"integrity": "sha512-QCOA9cgf3Rc33owG0AYBB9wszz+Ul2kramWN8tXG44Gyciud/tbkEqvxRF/IpqQaBpRBNi9f4jdNxqB2CQCIXg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
@@ -788,6 +913,50 @@
"node": ">=8.0.0"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -862,6 +1031,15 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
@@ -1090,6 +1268,20 @@
"node": ">=8"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/async-retry": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
@@ -1204,6 +1396,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/canonicalize": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz",
"integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==",
"license": "Apache-2.0",
"bin": {
"canonicalize": "bin/canonicalize.js"
}
},
"node_modules/cbor-extract": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz",
"integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.1.1"
},
"bin": {
"download-cbor-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@cbor-extract/cbor-extract-darwin-arm64": "2.2.2",
"@cbor-extract/cbor-extract-darwin-x64": "2.2.2",
"@cbor-extract/cbor-extract-linux-arm": "2.2.2",
"@cbor-extract/cbor-extract-linux-arm64": "2.2.2",
"@cbor-extract/cbor-extract-linux-x64": "2.2.2",
"@cbor-extract/cbor-extract-win32-x64": "2.2.2"
}
},
"node_modules/cbor-x": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.4.tgz",
"integrity": "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==",
"license": "MIT",
"optionalDependencies": {
"cbor-extract": "^2.2.2"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1337,6 +1569,39 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/did-jwt": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/did-jwt/-/did-jwt-7.4.7.tgz",
"integrity": "sha512-Apz7nIfIHSKWIMaEP5L/K8xkwByvjezjTG0xiqwKdnNj1x8M0+Yasury5Dm/KPltxi2PlGfRPf3IejRKZrT8mQ==",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^0.4.0",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.0",
"@scure/base": "^1.1.3",
"canonicalize": "^2.0.0",
"did-resolver": "^4.1.0",
"multibase": "^4.0.6",
"multiformats": "^9.6.2",
"uint8arrays": "3.1.1"
}
},
"node_modules/did-resolver": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz",
"integrity": "sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==",
"license": "Apache-2.0"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2542,6 +2807,26 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multibase": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/multibase/-/multibase-4.0.6.tgz",
"integrity": "sha512-x23pDe5+svdLz/k5JPGCVdfn7Q5mZVMBETiC+ORfO+sor9Sgs0smJzAjfTbM5tckeCqnaUuMYoz+k3RXMmJClQ==",
"deprecated": "This module has been superseded by the multiformats module",
"license": "MIT",
"dependencies": {
"@multiformats/base-x": "^4.0.1"
},
"engines": {
"node": ">=12.0.0",
"npm": ">=6.0.0"
}
},
"node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
"license": "(Apache-2.0 AND MIT)"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -2601,6 +2886,21 @@
"node": ">= 6.13.0"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
"integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -2746,6 +3046,24 @@
"node": ">= 0.10"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -3235,6 +3553,15 @@
"node": ">=14.17"
}
},
"node_modules/uint8arrays": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz",
"integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==",
"license": "MIT",
"dependencies": {
"multiformats": "^9.4.2"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View File

@@ -9,6 +9,11 @@
"build": "tsc"
},
"dependencies": {
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"cbor-x": "^1.5.9",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"express": "^5.1.0",
"firebase-admin": "^13.9.0"
},

View File

@@ -8,6 +8,7 @@ const dataFile = path.join(dataDir, "fcm-tokens.json");
export type StoredRow = {
id: string;
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
@@ -19,6 +20,7 @@ export type StoredRow = {
type ParsedRow = {
id?: string;
userId?: string;
deviceId?: string;
fcmToken: string;
platform: string;
@@ -28,8 +30,12 @@ type ParsedRow = {
lastNotifiedAt?: number | string;
};
export function storageKey(userId: string, deviceId: string): string {
return `${userId}::${deviceId}`;
}
function mergeDeviceRows(
deviceId: string,
key: string,
a: StoredRow,
b: StoredRow
): StoredRow {
@@ -43,7 +49,8 @@ function mergeDeviceRows(
return {
...primary,
id: primary.id,
deviceId,
userId: primary.userId,
deviceId: primary.deviceId,
fcmToken: primary.fcmToken,
lastNotifiedAt: lastMs > 0 ? lastMs : undefined,
createdAt: created,
@@ -77,8 +84,18 @@ function normalizeParsedRow(
onMutate();
}
let userId = r.userId?.trim();
if (userId === undefined || userId === "") {
const fromKey = mapKey.includes("::")
? mapKey.slice(0, mapKey.indexOf("::"))
: "";
userId = fromKey || "__legacy__";
onMutate();
}
return {
id,
userId,
deviceId,
fcmToken: r.fcmToken,
platform: r.platform,
@@ -89,6 +106,13 @@ function normalizeParsedRow(
};
}
function rowKey(row: StoredRow): string {
if (row.userId === "__legacy__") {
return row.deviceId;
}
return storageKey(row.userId, row.deviceId);
}
async function load(): Promise<Record<string, StoredRow>> {
try {
const raw = await readFile(dataFile, "utf8");
@@ -102,23 +126,21 @@ async function load(): Promise<Record<string, StoredRow>> {
for (const [mapKey, rawRow] of Object.entries(parsed)) {
const row = normalizeParsedRow(mapKey, rawRow, markDirty);
if (mapKey !== row.deviceId) markDirty();
const list = buckets.get(row.deviceId) ?? [];
const key = rowKey(row);
if (mapKey !== key) markDirty();
const list = buckets.get(key) ?? [];
list.push(row);
buckets.set(row.deviceId, list);
buckets.set(key, list);
}
const out: Record<string, StoredRow> = {};
for (const [did, rows] of buckets) {
for (const [key, rows] of buckets) {
if (rows.length === 1) {
out[did] = rows[0];
out[key] = rows[0];
} else {
out[did] = rows
out[key] = rows
.slice(1)
.reduce(
(acc, cur) => mergeDeviceRows(did, acc, cur),
rows[0]
);
.reduce((acc, cur) => mergeDeviceRows(key, acc, cur), rows[0]);
markDirty();
}
}
@@ -142,6 +164,7 @@ async function save(records: Record<string, StoredRow>): Promise<void> {
export const db = {
async upsert(row: {
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
@@ -149,11 +172,12 @@ export const db = {
updatedAt: Date;
}): Promise<void> {
const all = await load();
const key = row.deviceId;
const key = storageKey(row.userId, row.deviceId);
const prev = all[key];
const now = row.updatedAt.toISOString();
all[key] = {
id: prev?.id ?? randomUUID(),
userId: row.userId,
deviceId: row.deviceId,
fcmToken: row.fcmToken,
platform: row.platform,
@@ -164,7 +188,12 @@ export const db = {
};
for (const k of [...Object.keys(all)]) {
if (k !== key && all[k].fcmToken === row.fcmToken) {
const other = all[k];
if (
k !== key &&
other.userId === row.userId &&
other.fcmToken === row.fcmToken
) {
delete all[k];
}
}
@@ -177,9 +206,17 @@ export const db = {
return Object.values(all);
},
async getByDeviceId(deviceId: string): Promise<StoredRow | undefined> {
async getByUserId(userId: string): Promise<StoredRow[]> {
const all = await load();
return all[deviceId];
return Object.values(all).filter((r) => r.userId === userId);
},
async getByDeviceId(
userId: string,
deviceId: string
): Promise<StoredRow | undefined> {
const all = await load();
return all[storageKey(userId, deviceId)];
},
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {

84
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { NextFunction, Request, Response } from "express";
import { decodeAndVerifyJwt } from "../vc/index.js";
export type AuthContext = {
did: string;
jwt: string;
};
type ClientErrorBody = {
clientError?: {
message?: string;
code?: string;
};
};
function clientErrorMessage(err: unknown): string | undefined {
if (err && typeof err === "object" && "clientError" in err) {
const message = (err as ClientErrorBody).clientError?.message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return undefined;
}
/**
* Express middleware mirroring image-api decodeJwt: Bearer JWT, DID verification,
* attaches req.did / req.jwt / req.auth on success.
*/
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
console.log("[Auth] Authentication failed");
res.status(401).json({
success: false,
message: 'Missing "Bearer JWT" in Authorization header.',
});
return;
}
const token = authHeader.substring("Bearer ".length);
try {
const verified = await decodeAndVerifyJwt(token);
if (!verified.verified) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(
errorTime,
"Got invalid JWT in Authorization header:",
verified
);
res.status(401).json({
success: false,
message:
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
return;
}
const did = verified.issuer;
req.did = did;
req.jwt = token;
req.auth = { did, jwt: token };
console.log("[Auth] Authenticated user:", did);
next();
} catch (err) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(errorTime, "Got invalid JWT in Authorization header:", err);
res.status(401).json({
success: false,
message:
clientErrorMessage(err) ??
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
}
}

View File

@@ -1,6 +1,8 @@
export interface Device {
/** Internal row id used for persistence updates. */
id: string;
/** Authenticated user DID (from verified JWT). */
userId: string;
/** Client-provided stable physical device identity. */
deviceId: string;
fcmToken: string;

View File

@@ -1,5 +1,6 @@
import { Router } from "express";
import { db } from "../db/fcmTokens.js";
import { requireAuth } from "../middleware/auth.js";
export const notificationsRouter = Router();
@@ -7,8 +8,19 @@ notificationsRouter.get("/", (_req, res) => {
res.json({ ok: true, resource: "notifications" });
});
notificationsRouter.post("/refresh", async (_req, res) => {
console.log("[Refresh] Request received");
notificationsRouter.post("/refresh", requireAuth, async (req, res) => {
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const devices = await db.getByUserId(userId);
console.log(
"[Refresh] authenticated refresh request:",
userId,
`(${devices.length} device(s))`
);
const now = Date.now();
res.json({
@@ -17,7 +29,24 @@ notificationsRouter.post("/refresh", async (_req, res) => {
});
});
notificationsRouter.post("/register", async (req, res) => {
notificationsRouter.post("/register", requireAuth, async (req, res) => {
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
if (
req.body !== null &&
typeof req.body === "object" &&
"userId" in req.body
) {
res.status(400).json({
error: "userId must not be sent in the request body",
});
return;
}
const { deviceId, fcmToken, platform, testMode } = req.body as {
deviceId?: unknown;
fcmToken?: unknown;
@@ -41,13 +70,15 @@ notificationsRouter.post("/register", async (req, res) => {
const canonicalDeviceId = deviceId.trim();
try {
const existing = await db.getByDeviceId(canonicalDeviceId);
console.log("[Register] user authenticated:", userId);
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
console.log("[Register] Upserting device:", canonicalDeviceId);
if (existing !== undefined && existing.fcmToken !== fcmToken) {
console.log("[Register] Replacing token for device:", canonicalDeviceId);
}
await db.upsert({
userId,
deviceId: canonicalDeviceId,
fcmToken,
platform,

14
src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare global {
namespace Express {
interface Request {
/** Authenticated user DID (issuer from verified JWT). */
did?: string;
/** Raw Bearer JWT from the Authorization header. */
jwt?: string;
/** Verified auth context (did + jwt). */
auth?: { did: string; jwt: string };
}
}
}
export {};

View File

@@ -0,0 +1,46 @@
import type { DIDResolutionResult } from "did-resolver";
/**
* This did:ethr resolver instructs the did-jwt machinery to use the
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the
* signature to recover the DID's public key from a signature.
*
* Similar code resides in image-api, crowd-funder-for-time-pwa, and endorser-ch.
*/
export const didEthLocalResolver = async (
did: string
): Promise<DIDResolutionResult> => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/;
const match = did.match(didRegex);
if (match) {
const address = match[1];
const publicKeyHex = address;
return {
didDocumentMetadata: {},
didResolutionMetadata: {
contentType: "application/did+ld+json",
},
didDocument: {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1recovery-2020/v2",
],
id: did,
verificationMethod: [
{
id: `${did}#controller`,
type: "EcdsaSecp256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};

104
src/vc/didPeer.ts Normal file
View File

@@ -0,0 +1,104 @@
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
import crypto from "crypto";
import { decode as cborDecode } from "cbor-x";
/**
* similar code is in image-api and crowd-funder-for-time-pwa
*/
export async function verifyPeerSignature(
payloadBytes: Uint8Array,
publicKeyBytes: Uint8Array,
signatureBytes: Uint8Array
) {
const finalSignatureBuffer = unwrapEC2Signature(signatureBytes);
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await crypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"]
);
const verified = await crypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
finalSignatureBuffer,
payloadBytes
);
return verified;
}
function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes) as Record<number, unknown>;
if (
jwkObj[1] != 2 ||
jwkObj[3] != -7 ||
jwkObj[-1] != 1 ||
!Array.isArray(jwkObj[-2]) ||
(jwkObj[-2] as Uint8Array).length != 32 ||
!Array.isArray(jwkObj[-3]) ||
(jwkObj[-3] as Uint8Array).length != 32
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2] as Uint8Array),
y: arrayToBase64Url(jwkObj[-3] as Uint8Array),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2] as Uint8Array),
Buffer.from(jwkObj[-3] as Uint8Array),
]);
return { publicKeyJwk, publicKeyBuffer };
}
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
function unwrapEC2Signature(signature: Uint8Array) {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
return isoUint8ArrayConcat([rBytes, sBytes]);
}
function shouldRemoveLeadingZero(bytes: Uint8Array) {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
function isoUint8ArrayConcat(arrays: Uint8Array[]) {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}

100
src/vc/index.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* Verifiable Credential & DID functions (shared pattern with image-api, endorser-ch).
*/
import { verifyJWT } from "did-jwt";
import { Resolver } from "did-resolver";
import { didEthLocalResolver } from "./did-eth-local-resolver.js";
import { verifyJwt as peerVerifyJwt } from "./passkeyDidPeer.js";
export const TEST_BYPASS_ENV_VALUE = "test-local";
export const ETHR_DID_PREFIX = "did:ethr:";
export const PEER_DID_PREFIX = "did:peer:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED_CODE";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
const resolver = new Resolver({
ethr: didEthLocalResolver,
});
export type VerifiedJwt = {
issuer: string;
payload: Record<string, unknown>;
verified: boolean;
};
export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
const pieces = jwt.split(".");
const header = JSON.parse(
Buffer.from(pieces[0], "base64url").toString("utf8")
) as Record<string, unknown>;
const payload = JSON.parse(
Buffer.from(pieces[1], "base64url").toString("utf8")
) as Record<string, unknown>;
const issuerDid = payload.iss;
if (!issuerDid || typeof issuerDid !== "string") {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (
issuerDid.startsWith(ETHR_DID_PREFIX) &&
process.env.NODE_ENV === TEST_BYPASS_ENV_VALUE
) {
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (typeof payload.exp === "number" && payload.exp < nowEpoch) {
console.log(
"JWT with exp " +
payload.exp +
" has expired but we're in test mode so we'll use a new time."
);
payload.exp = nowEpoch + 100;
}
return { issuer: issuerDid, payload, verified: true };
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await verifyJWT(jwt, { resolver });
return verified as VerifiedJwt;
} catch (e) {
return Promise.reject({
clientError: {
message: `JWT failed verification: ` + e,
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const { claimPayload, verified } = await peerVerifyJwt(
payload,
issuerDid,
pieces[2]
);
return {
issuer: issuerDid,
payload: claimPayload as Record<string, unknown>,
verified,
};
}
if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({
clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`,
},
});
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}

119
src/vc/passkeyDidPeer.ts Normal file
View File

@@ -0,0 +1,119 @@
import crypto from "crypto";
import { multibaseToBytes } from "did-jwt";
import { PEER_DID_PREFIX, TEST_BYPASS_ENV_VALUE } from "./index.js";
import { verifyPeerSignature } from "./didPeer.js";
export async function verifyJwt(
payload: Record<string, unknown>,
issuerDid: string,
signatureString: string
) {
if (!payload.iss) {
return Promise.reject({
clientError: {
message: `JWT is missing an "iss" field.`,
},
});
}
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (!payload.exp) {
return Promise.reject({
clientError: {
message: `JWT with is missing an "exp" field.`,
},
});
}
if (
typeof payload.exp === "number" &&
payload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT with exp ${payload.exp} has expired.`,
},
});
}
const authData = payload.AuthenticationDataB64URL;
const clientData = payload.ClientDataJSONB64URL;
if (typeof authData !== "string" || typeof clientData !== "string") {
return Promise.reject({
clientError: {
message: `JWT with typ == JWANT requires AuthenticationData and ClientDataJSON.`,
},
});
}
const decodedAuthDataBuff = Buffer.from(authData, "base64url");
const decodedClientData = Buffer.from(clientData, "base64url");
let claimPayload = JSON.parse(decodedClientData.toString()) as Record<
string,
unknown
>;
if (claimPayload.challenge) {
claimPayload = JSON.parse(
Buffer.from(claimPayload.challenge as string, "base64url").toString()
) as Record<string, unknown>;
if (!claimPayload.exp) {
claimPayload.exp = payload.exp;
}
if (!claimPayload.iat) {
claimPayload.iat = payload.iat;
}
if (!claimPayload.iss) {
claimPayload.iss = payload.iss;
}
}
if (!claimPayload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge is missing an "exp" field.`,
},
});
}
if (
typeof claimPayload.exp === "number" &&
claimPayload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT client data challenge exp time is past.`,
},
});
}
if (claimPayload.exp !== payload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "exp" field doesn't match the outside payload "exp".`,
},
});
}
if (claimPayload.iss !== payload.iss) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "iss" field doesn't match the outside payload "iss".`,
},
});
}
const hashedClientDataBuff = crypto
.createHash("sha256")
.update(decodedClientData)
.digest();
const preimage = new Uint8Array(
Buffer.concat([decodedAuthDataBuff, hashedClientDataBuff])
);
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
const publicKey = multibaseToBytes(
issuerDid.substring(PEER_DID_MULTIBASE_PREFIX.length)
);
const signature = new Uint8Array(
Buffer.from(signatureString, "base64url")
);
const verified = await verifyPeerSignature(preimage, publicKey, signature);
return { claimPayload, verified };
}