Compare commits
22 Commits
main
...
2dba6c3597
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dba6c3597 | |||
|
|
6ba7d678c6 | ||
|
|
dffb86007e | ||
| a2e5fa0ab9 | |||
| c010c861b4 | |||
| df442df869 | |||
|
|
03ebe03021 | ||
|
|
f12dd03725 | ||
|
|
e82c3ae5bc | ||
|
|
9764b30aed | ||
|
|
afbc2e9a57 | ||
|
|
8e502a2335 | ||
|
|
4bf57d26fd | ||
|
|
fc0cad4f2e | ||
|
|
e92ddb7da9 | ||
|
|
86d589d0e8 | ||
|
|
096f393df9 | ||
|
|
a5266615eb | ||
|
|
1115929437 | ||
|
|
2b57ec0e1c | ||
|
|
64ea7d2f98 | ||
|
|
d311b6a504 |
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# HTTP port (default: 3000)
|
||||
PORT=3000
|
||||
|
||||
# Firebase Admin: inline service account JSON (one line).
|
||||
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
|
||||
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
|
||||
|
||||
# Local persistence directory for registered FCM tokens (default: ./data)
|
||||
# FCM_TOKEN_DATA_DIR=./data
|
||||
|
||||
# Set to "test-local" to bypass ethr JWT expiry verification in local dev only.
|
||||
# NODE_ENV=test-local
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
data/
|
||||
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --prod
|
||||
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
A lightweight Express service that schedules and sends Firebase Cloud Messaging (FCM) push notifications to wake up registered devices.
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit .env — set FIREBASE_SERVICE_ACCOUNT_JSON
|
||||
Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .`
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:3000` (or the port in `PORT`). Hot-reloads on file changes.
|
||||
|
||||
Set `NODE_ENV=test-local` in `.env` to bypass JWT expiry verification during local development.
|
||||
|
||||
## Production
|
||||
|
||||
Runs TypeScript directly via `tsx` (no compile step).
|
||||
|
||||
```bash
|
||||
pnpm install --prod
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t notification-wakeup-service .
|
||||
docker run -e FIREBASE_SERVICE_ACCOUNT_JSON='...' -p 3000:3000 notification-wakeup-service
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
|
||||
| `PORT` | HTTP port (default: `3000`). |
|
||||
| `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |
|
||||
2309
package-lock.json
generated
2309
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -3,18 +3,28 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0"
|
||||
"@peculiar/asn1-ecc": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"cbor-x": "^1.6.4",
|
||||
"cors": "^2.8.6",
|
||||
"did-jwt": "^7.4.7",
|
||||
"did-resolver": "^4.1.0",
|
||||
"express": "^5.2.1",
|
||||
"firebase-admin": "^13.10.0",
|
||||
"tsx": "^4.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.19.19",
|
||||
"@types/cors": "^2.8.19",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
2524
pnpm-lock.yaml
generated
Normal file
2524
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
pnpm-workspace.yaml
Normal file
11
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
strictDepBuilds: false
|
||||
onlyBuiltDependencies:
|
||||
- "@firebase/util"
|
||||
- cbor-extract
|
||||
- esbuild
|
||||
- protobufjs
|
||||
allowBuilds:
|
||||
'@firebase/util': set this to true or false
|
||||
cbor-extract: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
protobufjs: set this to true or false
|
||||
290
src/db/fcmTokens.ts
Normal file
290
src/db/fcmTokens.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir =
|
||||
process.env.FCM_TOKEN_DATA_DIR ?? path.join(process.cwd(), "data");
|
||||
const dataFile = path.join(dataDir, "fcm-tokens.json");
|
||||
|
||||
export type StoredRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
fcmToken: string;
|
||||
platform: string;
|
||||
testMode?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastNotifiedAt?: number;
|
||||
};
|
||||
|
||||
type ParsedRow = {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
fcmToken: string;
|
||||
platform: string;
|
||||
testMode?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastNotifiedAt?: number | string;
|
||||
};
|
||||
|
||||
export function storageKey(userId: string, deviceId: string): string {
|
||||
return `${userId}::${deviceId}`;
|
||||
}
|
||||
|
||||
function mergeDeviceRows(
|
||||
key: string,
|
||||
a: StoredRow,
|
||||
b: StoredRow
|
||||
): StoredRow {
|
||||
const primary =
|
||||
new Date(a.updatedAt) >= new Date(b.updatedAt) ? a : b;
|
||||
const lastMs = Math.max(a.lastNotifiedAt ?? 0, b.lastNotifiedAt ?? 0);
|
||||
const created =
|
||||
new Date(a.createdAt) <= new Date(b.createdAt)
|
||||
? a.createdAt
|
||||
: b.createdAt;
|
||||
return {
|
||||
...primary,
|
||||
id: primary.id,
|
||||
userId: primary.userId,
|
||||
deviceId: primary.deviceId,
|
||||
fcmToken: primary.fcmToken,
|
||||
lastNotifiedAt: lastMs > 0 ? lastMs : undefined,
|
||||
createdAt: created,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeParsedRow(
|
||||
mapKey: string,
|
||||
r: ParsedRow,
|
||||
onMutate: () => void
|
||||
): StoredRow {
|
||||
let id = r.id;
|
||||
if (id === undefined || id === "") {
|
||||
id = randomUUID();
|
||||
onMutate();
|
||||
}
|
||||
|
||||
let lastNotifiedAt: number | undefined;
|
||||
if (typeof r.lastNotifiedAt === "string") {
|
||||
const ms = Date.parse(r.lastNotifiedAt);
|
||||
lastNotifiedAt = Number.isNaN(ms) ? undefined : ms;
|
||||
onMutate();
|
||||
} else if (typeof r.lastNotifiedAt === "number") {
|
||||
lastNotifiedAt = Number.isNaN(r.lastNotifiedAt)
|
||||
? undefined
|
||||
: r.lastNotifiedAt;
|
||||
}
|
||||
|
||||
const deviceId = (r.deviceId ?? r.fcmToken ?? mapKey).trim();
|
||||
if (r.deviceId === undefined || r.deviceId === "") {
|
||||
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,
|
||||
testMode: r.testMode,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
lastNotifiedAt,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
const parsed = JSON.parse(raw) as Record<string, ParsedRow>;
|
||||
let dirty = false;
|
||||
const markDirty = (): void => {
|
||||
dirty = true;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, StoredRow[]>();
|
||||
|
||||
for (const [mapKey, rawRow] of Object.entries(parsed)) {
|
||||
const row = normalizeParsedRow(mapKey, rawRow, markDirty);
|
||||
const key = rowKey(row);
|
||||
if (mapKey !== key) markDirty();
|
||||
const list = buckets.get(key) ?? [];
|
||||
list.push(row);
|
||||
buckets.set(key, list);
|
||||
}
|
||||
|
||||
const out: Record<string, StoredRow> = {};
|
||||
for (const [key, rows] of buckets) {
|
||||
if (rows.length === 1) {
|
||||
out[key] = rows[0];
|
||||
} else {
|
||||
out[key] = rows
|
||||
.slice(1)
|
||||
.reduce((acc, cur) => mergeDeviceRows(key, acc, cur), rows[0]);
|
||||
markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) await save(out);
|
||||
return out;
|
||||
} catch (e: unknown) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") return {};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(records: Record<string, StoredRow>): Promise<void> {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const tmp = path.join(dataDir, `.fcm-tokens.${process.pid}.tmp`);
|
||||
const payload = JSON.stringify(records, null, 2);
|
||||
await writeFile(tmp, payload, "utf8");
|
||||
await rename(tmp, dataFile);
|
||||
}
|
||||
|
||||
export const db = {
|
||||
async upsert(row: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
fcmToken: string;
|
||||
platform: string;
|
||||
testMode?: boolean;
|
||||
updatedAt: Date;
|
||||
}): Promise<void> {
|
||||
const all = await load();
|
||||
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,
|
||||
testMode: row.testMode,
|
||||
updatedAt: now,
|
||||
createdAt: prev?.createdAt ?? now,
|
||||
lastNotifiedAt: prev?.lastNotifiedAt,
|
||||
};
|
||||
|
||||
for (const k of [...Object.keys(all)]) {
|
||||
const other = all[k];
|
||||
if (
|
||||
k !== key &&
|
||||
other.userId === row.userId &&
|
||||
other.fcmToken === row.fcmToken
|
||||
) {
|
||||
delete all[k];
|
||||
}
|
||||
}
|
||||
|
||||
await save(all);
|
||||
},
|
||||
|
||||
async getAll(): Promise<StoredRow[]> {
|
||||
const all = await load();
|
||||
return Object.values(all);
|
||||
},
|
||||
|
||||
/** Scheduler iteration; excludes legacy rows pending migration cleanup. */
|
||||
async getAllForScheduler(): Promise<StoredRow[]> {
|
||||
const all = await load();
|
||||
// TODO: migrate or remove __legacy__ rows after auth rollout
|
||||
return Object.values(all).filter((r) => r.userId !== "__legacy__");
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a device owned by userId via deviceId and/or fcmToken.
|
||||
* When both are given, they must refer to the same row.
|
||||
*/
|
||||
async resolveOwnedDevice(
|
||||
userId: string,
|
||||
query: { deviceId?: string; fcmToken?: string }
|
||||
): Promise<StoredRow | undefined> {
|
||||
const deviceId = query.deviceId?.trim();
|
||||
const fcmToken = query.fcmToken;
|
||||
|
||||
if (deviceId !== undefined && deviceId.length > 0) {
|
||||
const byDevice = await this.getByDeviceId(userId, deviceId);
|
||||
if (byDevice === undefined) return undefined;
|
||||
if (
|
||||
fcmToken !== undefined &&
|
||||
fcmToken.length > 0 &&
|
||||
byDevice.fcmToken !== fcmToken
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return byDevice;
|
||||
}
|
||||
|
||||
if (fcmToken !== undefined && fcmToken.length > 0) {
|
||||
return this.getByFcmTokenForUser(userId, fcmToken);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async getByUserId(userId: string): Promise<StoredRow[]> {
|
||||
const all = await load();
|
||||
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> {
|
||||
const all = await load();
|
||||
const matches = Object.values(all).filter((r) => r.fcmToken === fcmToken);
|
||||
if (matches.length === 0) return undefined;
|
||||
const owned = matches.filter((r) => r.userId !== "__legacy__");
|
||||
const pool = owned.length > 0 ? owned : matches;
|
||||
return pool.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)[0];
|
||||
},
|
||||
|
||||
async getByFcmTokenForUser(
|
||||
userId: string,
|
||||
fcmToken: string
|
||||
): Promise<StoredRow | undefined> {
|
||||
const all = await load();
|
||||
return Object.values(all).find(
|
||||
(r) => r.userId === userId && r.fcmToken === fcmToken
|
||||
);
|
||||
},
|
||||
|
||||
async update(id: string, patch: { lastNotifiedAt: number }): Promise<void> {
|
||||
const all = await load();
|
||||
const found = Object.entries(all).find(([, r]) => r.id === id);
|
||||
if (found === undefined) return;
|
||||
const [key, row] = found;
|
||||
all[key] = { ...row, ...patch };
|
||||
await save(all);
|
||||
},
|
||||
};
|
||||
15
src/index.ts
15
src/index.ts
@@ -1,17 +1,30 @@
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import "./services/firebase.js";
|
||||
import { debugRouter } from "./routes/debug.js";
|
||||
import { notificationsRouter } from "./routes/notifications.js";
|
||||
import { startScheduler } from "./scheduler.js";
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Keep stable for diagnostics tooling compatibility
|
||||
app.get("/health", (_req, res) => {
|
||||
res.status(200).json({ status: "ok" });
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
app.use("/notifications", notificationsRouter);
|
||||
app.use("/debug", debugRouter);
|
||||
|
||||
startScheduler();
|
||||
|
||||
|
||||
88
src/middleware/auth.ts
Normal file
88
src/middleware/auth.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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(
|
||||
"[Auth] Invalid JWT at",
|
||||
errorTime + ":",
|
||||
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(
|
||||
"[Auth] Invalid JWT at",
|
||||
errorTime + ":",
|
||||
err
|
||||
);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message:
|
||||
clientErrorMessage(err) ??
|
||||
"Got invalid JWT in Authorization header. See server logs at " +
|
||||
errorTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
export interface Device {
|
||||
/** Internal row id used for persistence updates. */
|
||||
id: string;
|
||||
pushToken: string;
|
||||
/** Authenticated user DID (from verified JWT). */
|
||||
userId: string;
|
||||
/** Client-provided stable physical device identity. */
|
||||
deviceId: string;
|
||||
fcmToken: string;
|
||||
platform: "ios" | "android" | "web";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
/** Epoch ms; set only after a successful push send. */
|
||||
lastNotifiedAt?: number;
|
||||
}
|
||||
|
||||
143
src/routes/debug.ts
Normal file
143
src/routes/debug.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db/fcmTokens.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import {
|
||||
computeNextEligibleAt,
|
||||
sendPushToDevice,
|
||||
} from "../services/pushService.js";
|
||||
import { formatElapsedMs } from "../util/formatElapsed.js";
|
||||
import { maskToken } from "../util/maskToken.js";
|
||||
|
||||
// TODO: Protect this endpoint before production deployment
|
||||
export const debugRouter = Router();
|
||||
|
||||
debugRouter.use(requireAuth);
|
||||
|
||||
function deviceDebugPayload(row: {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
platform: string;
|
||||
testMode?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastNotifiedAt?: number;
|
||||
fcmToken: string;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
deviceId: row.deviceId,
|
||||
platform: row.platform,
|
||||
testMode: row.testMode ?? false,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
lastNotifiedAt: row.lastNotifiedAt,
|
||||
nextEligibleAt: computeNextEligibleAt(row),
|
||||
fcmTokenSuffix: maskToken(row.fcmToken),
|
||||
};
|
||||
}
|
||||
|
||||
function sendWakeupFailureReason(
|
||||
result: "sent" | "skipped" | "failed"
|
||||
): string | undefined {
|
||||
if (result === "sent") return undefined;
|
||||
if (result === "skipped") {
|
||||
return "Device was notified within the eligibility threshold";
|
||||
}
|
||||
return "FCM send failed";
|
||||
}
|
||||
|
||||
// TODO: Protect this endpoint before production deployment
|
||||
debugRouter.get("/device/:token", async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const fcmToken = decodeURIComponent(req.params.token);
|
||||
const suffix = maskToken(fcmToken);
|
||||
console.log("[DebugEndpoint] Device lookup request, token suffix:", suffix);
|
||||
|
||||
const row = await db.resolveOwnedDevice(userId, { fcmToken });
|
||||
if (row === undefined) {
|
||||
console.log(
|
||||
"[DebugEndpoint] Device lookup not found in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"token suffix:",
|
||||
suffix
|
||||
);
|
||||
res.status(404).json({ error: "Device not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(deviceDebugPayload(row));
|
||||
console.log(
|
||||
"[DebugEndpoint] Device lookup completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"token suffix:",
|
||||
suffix
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: Protect this endpoint before production deployment
|
||||
debugRouter.post("/send-wakeup", async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { fcmToken } = req.body as { fcmToken?: unknown };
|
||||
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
||||
console.log(
|
||||
"[DebugEndpoint] Send-wakeup rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"fcmToken is required"
|
||||
);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
failureReason: "fcmToken is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = maskToken(fcmToken);
|
||||
console.log("[DebugEndpoint] Send-wakeup request, token suffix:", suffix);
|
||||
|
||||
const row = await db.resolveOwnedDevice(userId, { fcmToken });
|
||||
if (row === undefined) {
|
||||
console.log(
|
||||
"[DebugEndpoint] Send-wakeup rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"token suffix:",
|
||||
suffix + ",",
|
||||
"reason: Device not found"
|
||||
);
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
failureReason: "Device not found",
|
||||
fcmTokenSuffix: suffix,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendPushToDevice(fcmToken);
|
||||
const success = result === "sent";
|
||||
const failureReason = sendWakeupFailureReason(result);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
...(failureReason !== undefined ? { failureReason } : {}),
|
||||
fcmTokenSuffix: suffix,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[DebugEndpoint] Send-wakeup completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
success ? "success" : result + ",",
|
||||
"token suffix:",
|
||||
suffix
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,221 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { db } from "../db/fcmTokens.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||
import { maskToken } from "../util/maskToken.js";
|
||||
|
||||
/** Synthetic userId for unauthenticated local debug registrations (testMode). */
|
||||
const LOCAL_TEST_USER_ID = "__notification_local_test__";
|
||||
|
||||
function isNotificationLocalTestBypass(req: Request): boolean {
|
||||
if (req.headers.authorization?.startsWith("Bearer ")) {
|
||||
return false;
|
||||
}
|
||||
const body = req.body;
|
||||
return (
|
||||
body !== null &&
|
||||
typeof body === "object" &&
|
||||
(body as { testMode?: unknown }).testMode === true
|
||||
);
|
||||
}
|
||||
|
||||
async function requireAuthOrNotificationLocalTest(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
if (isNotificationLocalTestBypass(req)) {
|
||||
req.did = LOCAL_TEST_USER_ID;
|
||||
console.log("[Auth] Local notification test bypass");
|
||||
next();
|
||||
return;
|
||||
}
|
||||
return requireAuth(req, res, next);
|
||||
}
|
||||
|
||||
export const notificationsRouter = Router();
|
||||
|
||||
notificationsRouter.get("/", (_req, res) => {
|
||||
res.json({ ok: true, resource: "notifications" });
|
||||
});
|
||||
|
||||
notificationsRouter.post(
|
||||
"/refresh",
|
||||
requireAuthOrNotificationLocalTest,
|
||||
async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { deviceId, fcmToken } = req.body as {
|
||||
deviceId?: unknown;
|
||||
fcmToken?: unknown;
|
||||
};
|
||||
|
||||
const canonicalDeviceId =
|
||||
typeof deviceId === "string" ? deviceId.trim() : undefined;
|
||||
const token =
|
||||
typeof fcmToken === "string" && fcmToken.length > 0
|
||||
? fcmToken
|
||||
: undefined;
|
||||
|
||||
console.log(
|
||||
"[Refresh] Request received",
|
||||
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
|
||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
||||
);
|
||||
|
||||
if (
|
||||
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
|
||||
token === undefined
|
||||
) {
|
||||
console.log(
|
||||
"[Refresh] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"deviceId or fcmToken is required"
|
||||
);
|
||||
res.status(400).json({ error: "deviceId or fcmToken is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await db.resolveOwnedDevice(userId, {
|
||||
deviceId: canonicalDeviceId,
|
||||
fcmToken: token,
|
||||
});
|
||||
|
||||
if (device === undefined) {
|
||||
console.log(
|
||||
"[Refresh] Device not found in",
|
||||
formatElapsedMs(Date.now() - started),
|
||||
canonicalDeviceId !== undefined
|
||||
? `deviceId=${canonicalDeviceId}`
|
||||
: "",
|
||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
||||
);
|
||||
res.status(404).json({ error: "Device not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
res.json({
|
||||
shouldNotify: true,
|
||||
nextNotifications: [{ timestamp: now + 600000 }],
|
||||
});
|
||||
console.log(
|
||||
"[Refresh] Completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + device.deviceId + ",",
|
||||
"token suffix=" + maskToken(device.fcmToken)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
notificationsRouter.post(
|
||||
"/register",
|
||||
requireAuthOrNotificationLocalTest,
|
||||
async (req, res) => {
|
||||
const started = Date.now();
|
||||
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
|
||||
) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"userId must not be sent in the request 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;
|
||||
platform?: unknown;
|
||||
testMode?: unknown;
|
||||
};
|
||||
|
||||
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"deviceId is required"
|
||||
);
|
||||
res.status(400).json({ error: "deviceId is required" });
|
||||
return;
|
||||
}
|
||||
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"fcmToken is required"
|
||||
);
|
||||
res.status(400).json({ error: "fcmToken is required" });
|
||||
return;
|
||||
}
|
||||
if (typeof platform !== "string" || platform.length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"platform is required"
|
||||
);
|
||||
res.status(400).json({ error: "platform is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const canonicalDeviceId = deviceId.trim();
|
||||
console.log(
|
||||
"[Register] Request received,",
|
||||
"deviceId=" + canonicalDeviceId + ",",
|
||||
"platform=" + platform + ",",
|
||||
"token suffix=" + maskToken(fcmToken)
|
||||
);
|
||||
|
||||
try {
|
||||
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
|
||||
const action =
|
||||
existing === undefined
|
||||
? "create"
|
||||
: existing.fcmToken !== fcmToken
|
||||
? "update-token"
|
||||
: "update";
|
||||
|
||||
await db.upsert({
|
||||
userId,
|
||||
deviceId: canonicalDeviceId,
|
||||
fcmToken,
|
||||
platform,
|
||||
testMode: typeof testMode === "boolean" ? testMode : undefined,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
res.sendStatus(200);
|
||||
console.log(
|
||||
"[Register] Completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + canonicalDeviceId + ",",
|
||||
"action=" + action
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Register] Failed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + canonicalDeviceId + ":",
|
||||
errorMessage(err)
|
||||
);
|
||||
res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,11 +1,61 @@
|
||||
import { db } from "./db/fcmTokens.js";
|
||||
import { sendPushToDevice } from "./services/pushService.js";
|
||||
import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js";
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (intervalId !== undefined) return;
|
||||
// TODO: replace with job queue or cron for wake-up checks
|
||||
intervalId = setInterval(() => {
|
||||
// placeholder tick
|
||||
}, 60_000);
|
||||
|
||||
intervalId = setInterval(async () => {
|
||||
const passStarted = Date.now();
|
||||
console.log("[Scheduler] Pass started");
|
||||
|
||||
try {
|
||||
const devices = await db.getAllForScheduler();
|
||||
const seenTokens = new Set<string>();
|
||||
let checked = 0;
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
let duplicates = 0;
|
||||
|
||||
for (const d of devices) {
|
||||
if (seenTokens.has(d.fcmToken)) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
seenTokens.add(d.fcmToken);
|
||||
checked++;
|
||||
|
||||
const result = await sendPushToDevice(d.fcmToken);
|
||||
if (result === "sent") sent++;
|
||||
else if (result === "skipped") skipped++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
const summaryParts = [
|
||||
`Checked ${checked} devices`,
|
||||
`sent ${sent} pushes`,
|
||||
`skipped ${skipped}`,
|
||||
];
|
||||
if (failed > 0) summaryParts.push(`failed ${failed}`);
|
||||
if (duplicates > 0) {
|
||||
summaryParts.push(`${duplicates} duplicates ignored`);
|
||||
}
|
||||
console.log("[Scheduler]", summaryParts.join(", "));
|
||||
console.log(
|
||||
"[Scheduler] Pass completed in",
|
||||
formatElapsedMs(Date.now() - passStarted)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Scheduler] Pass failed in",
|
||||
formatElapsedMs(Date.now() - passStarted) + ":",
|
||||
errorMessage(err)
|
||||
);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function stopScheduler(): void {
|
||||
|
||||
19
src/services/firebase.ts
Normal file
19
src/services/firebase.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import admin from "firebase-admin";
|
||||
import type { ServiceAccount } from "firebase-admin/app";
|
||||
import type { Messaging } from "firebase-admin/messaging";
|
||||
|
||||
function resolveCredential(): admin.credential.Credential {
|
||||
const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
|
||||
if (json !== undefined && json.trim() !== "") {
|
||||
return admin.credential.cert(JSON.parse(json) as ServiceAccount);
|
||||
}
|
||||
return admin.credential.applicationDefault();
|
||||
}
|
||||
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp({
|
||||
credential: resolveCredential(),
|
||||
});
|
||||
}
|
||||
|
||||
export const messaging: Messaging = admin.messaging();
|
||||
@@ -1,8 +1,108 @@
|
||||
import type { Device } from "../models/device.js";
|
||||
import { db, type StoredRow } from "../db/fcmTokens.js";
|
||||
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||
import { maskToken } from "../util/maskToken.js";
|
||||
import { messaging } from "./firebase.js";
|
||||
|
||||
export async function sendPushToDevice(
|
||||
_device: Device,
|
||||
_payload: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
// TODO: integrate with push provider (FCM, APNs, etc.)
|
||||
const MS_PRODUCTION = 23 * 60 * 60 * 1000;
|
||||
const MS_TEST = 10 * 60 * 1000;
|
||||
|
||||
export function notifyThresholdMs(testMode?: boolean): number {
|
||||
return testMode === true ? MS_TEST : MS_PRODUCTION;
|
||||
}
|
||||
|
||||
/** Epoch ms when the device may receive another push (diagnostics only). */
|
||||
export function computeNextEligibleAt(row: {
|
||||
lastNotifiedAt?: number;
|
||||
testMode?: boolean;
|
||||
}): number {
|
||||
const threshold = notifyThresholdMs(row.testMode);
|
||||
if (row.lastNotifiedAt === undefined) {
|
||||
return Date.now();
|
||||
}
|
||||
return row.lastNotifiedAt + threshold;
|
||||
}
|
||||
|
||||
function lastNotifiedMs(row: StoredRow | undefined): number | undefined {
|
||||
const v = row?.lastNotifiedAt;
|
||||
if (v === undefined) return undefined;
|
||||
if (typeof v === "number") return Number.isNaN(v) ? undefined : v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringifyData(
|
||||
payload: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
out[k] = v === undefined || v === null ? "" : String(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an FCM data message if the token is outside the dedupe window
|
||||
* (23h production, 10m test).
|
||||
*/
|
||||
export async function sendPushToDevice(
|
||||
fcmToken: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): Promise<"sent" | "skipped" | "failed"> {
|
||||
const suffix = maskToken(fcmToken);
|
||||
const row = await db.getByFcmToken(fcmToken);
|
||||
const now = Date.now();
|
||||
const last = lastNotifiedMs(row);
|
||||
|
||||
if (
|
||||
last !== undefined &&
|
||||
now - last < notifyThresholdMs(row?.testMode)
|
||||
) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const sendStarted = Date.now();
|
||||
console.log("[Push] Send attempt, token suffix:", suffix);
|
||||
|
||||
try {
|
||||
const data: Record<string, string> = {
|
||||
...stringifyData(payload),
|
||||
type: "WAKEUP_PING",
|
||||
};
|
||||
|
||||
await messaging.send({
|
||||
token: fcmToken,
|
||||
apns: {
|
||||
headers: {
|
||||
"apns-push-type": "background",
|
||||
"apns-priority": "5",
|
||||
},
|
||||
payload: {
|
||||
aps: {
|
||||
contentAvailable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
const persisted = await db.getByFcmToken(fcmToken);
|
||||
if (persisted !== undefined) {
|
||||
await db.update(persisted.id, { lastNotifiedAt: Date.now() });
|
||||
}
|
||||
console.log(
|
||||
"[Push] Send completed in",
|
||||
formatElapsedMs(Date.now() - sendStarted) + ",",
|
||||
"token suffix:",
|
||||
suffix
|
||||
);
|
||||
return "sent";
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Push] Send failed in",
|
||||
formatElapsedMs(Date.now() - sendStarted) + ",",
|
||||
"token suffix:",
|
||||
suffix + ":",
|
||||
errorMessage(err)
|
||||
);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
14
src/types/express.d.ts
vendored
Normal file
14
src/types/express.d.ts
vendored
Normal 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 {};
|
||||
17
src/util/formatElapsed.ts
Normal file
17
src/util/formatElapsed.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Human-readable duration for console logs (e.g. 842ms, 2.1s). */
|
||||
export function formatElapsedMs(elapsedMs: number): string {
|
||||
if (elapsedMs < 1000) {
|
||||
return `${Math.round(elapsedMs)}ms`;
|
||||
}
|
||||
return `${(elapsedMs / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error && err.message.length > 0) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string" && err.length > 0) {
|
||||
return err;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
7
src/util/maskToken.ts
Normal file
7
src/util/maskToken.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Last 6 characters only — safe for logs and debug responses. */
|
||||
export function maskToken(token: string): string {
|
||||
if (token.length <= 6) {
|
||||
return "******";
|
||||
}
|
||||
return token.slice(-6);
|
||||
}
|
||||
46
src/vc/did-eth-local-resolver.ts
Normal file
46
src/vc/did-eth-local-resolver.ts
Normal 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
104
src/vc/didPeer.ts
Normal 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
100
src/vc/index.ts
Normal 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
119
src/vc/passkeyDidPeer.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user