Compare commits

28 Commits
main ... master

Author SHA1 Message Date
Jose Olarte III
fcf3fb9aa0 chore: ignore .DS_Store files 2026-07-02 17:14:11 +08:00
a99117dc98 bump to v 0.1.1, change default port to 3003, fix Dockerfile to build & run separately 2026-06-28 17:29:21 -06:00
85301135c7 fix type errors 2026-06-28 17:28:54 -06:00
Jose Olarte III
7a2bb88207 feat(config): load .env and log Firebase credential source at startup
Add dotenv via src/env.ts so dev/start read .env before Firebase init,
and log whether credentials come from FIREBASE_SERVICE_ACCOUNT_JSON or ADC.
2026-06-11 17:50:16 +08:00
Jose Olarte III
6261f1baa0 feat(debug): allow local testMode auth on send-wakeup
Move requireAuthOrNotificationLocalTest into shared auth middleware and
apply it only to POST /debug/send-wakeup so local testing matches
register/refresh without changing JWT-authenticated behavior.
2026-06-11 17:24:06 +08:00
bb0927ad92 Merge pull request 'Dockerize' (#1) from docker into master
Reviewed-on: #1
2026-06-06 01:31:52 +00:00
2dba6c3597 Merge branch 'master' into docker 2026-06-05 19:30:50 -06:00
Jose Olarte III
6ba7d678c6 feat(notifications): allow local debug register/refresh without JWT
When the Notification Debug Panel sends testMode: true and omits
Authorization, skip requireAuth on /notifications/register and /refresh
and scope devices under a synthetic local-test user id. Requests with
a Bearer token or without testMode still use full JWT auth unchanged.
2026-06-04 20:32:18 +08:00
Jose Olarte III
dffb86007e fix(cors): enable preflight for Capacitor WebView requests
Add express cors middleware with reflected origin so Android clients
from https://localhost receive Access-Control-Allow-* headers on
OPTIONS and can proceed with POST requests.
2026-06-04 18:26:22 +08:00
a2e5fa0ab9 fix the pnpm build -- 'docker build' now works 2026-06-02 20:37:29 -06:00
c010c861b4 attempt a Docker file but it fails on step 5 2026-05-27 20:37:33 -06:00
df442df869 add a README.md with basics, and target pnpm instead of npm 2026-05-27 20:02:24 -06:00
Jose Olarte III
03ebe03021 chore: add .env.example for local setup
Document PORT, Firebase credentials, FCM token data dir, and test-local
NODE_ENV so developers can copy the file to .env without guessing vars.
2026-05-24 10:25:45 +08:00
Jose Olarte III
f12dd03725 chore(logging): normalize wakeup flow observability with timings and summaries
Standardize console prefixes across scheduler, push, refresh, register,
auth, and debug endpoints. Add pass-level scheduler summaries, elapsed-time
logs, and masked-token-only push failure messages while reducing per-device
noise in scheduler loops.
2026-05-21 19:18:28 +08:00
Jose Olarte III
e82c3ae5bc feat(debug): expose nextEligibleAt and structured send-wakeup results
Extend authenticated debug endpoints for local iOS notification testing:
add nextEligibleAt (23h prod / 10m test) to device lookup, return success
and failureReason from send-wakeup with masked tokens only, reuse
resolveOwnedDevice for ownership checks, and standardize [DebugEndpoint] logs.
2026-05-21 18:23:50 +08:00
Jose Olarte III
9764b30aed fix(auth): harden refresh ownership and scheduler after auth migration
Restore /health to { ok: true }. Scope refresh to owned devices via
deviceId/fcmToken, improve register upsert logging, skip legacy rows in
the scheduler with per-token dedupe, and prefer non-legacy rows for push.
2026-05-19 19:53:21 +08:00
Jose Olarte III
afbc2e9a57 feat(debug): harden debug routes with auth and user-scoped token access
Add GET /debug/device/:token and POST /debug/send-wakeup behind requireAuth,
scope lookups to the authenticated user (404 otherwise), and mask FCM tokens
in logs via maskToken. Mark routes for further restriction before production.
2026-05-19 19:42:22 +08:00
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
Jose Olarte III
fc0cad4f2e feat(register): key devices by deviceId and replace FCM tokens in place
Require deviceId on POST /notifications/register, upsert by deviceId
while preserving lastNotifiedAt and internal id, prune duplicate token
rows, migrate legacy fcmToken-keyed JSON, and add register logs.
Extend StoredRow and Device with deviceId; resolve pushes by scanning
fcmToken.
2026-05-12 21:44:59 +08:00
Jose Olarte III
e92ddb7da9 chore(obs): add lightweight console logs for scheduler and push
Log scheduler ticks, refresh requests, dedupe skips by device id,
push attempt/success with token hints, and push failures without
extra sensitive fields.
2026-05-12 18:41:41 +08:00
Jose Olarte III
86d589d0e8 feat(db): track last push time by device id with numeric timestamps
Assign stable ids to stored tokens, migrate legacy ISO lastNotifiedAt
to epoch ms, replace setLastNotifiedAt with db.update, and persist
lastNotifiedAt only after a successful FCM send. Extend Device with
optional lastNotifiedAt (ms).
2026-05-11 21:23:10 +08:00
Jose Olarte III
096f393df9 feat(scheduler): run wake pushes every five minutes
Add db.getAll for registered tokens and tick the interval with
sendPushToDevice per device, with error logging on tick failure.
2026-05-11 18:35:45 +08:00
Jose Olarte III
a5266615eb feat(push): send silent iOS wakeups via FCM APNs background
Use apns-push-type background, priority 5, contentAvailable-only aps
payload, and WAKEUP_PING data without alert or sound.
2026-05-11 17:09:01 +08:00
Jose Olarte III
1115929437 feat(push): dedupe FCM sends with 23h / 10m windows
Track lastNotifiedAt on stored tokens, preserve it on register upsert,
and skip messaging.send when inside the production or test-mode window.
2026-05-11 16:56:07 +08:00
Jose Olarte III
2b57ec0e1c feat(notifications): add POST /refresh schedule response
Return shouldNotify and nextNotifications with a 10-minute lookahead
timestamp for the app schedule API.
2026-05-11 15:59:26 +08:00
Jose Olarte III
64ea7d2f98 feat(notifications): persist FCM tokens on POST /register
Add JSON-backed upsert store (data/fcm-tokens.json, optional
FCM_TOKEN_DATA_DIR), validate body fields, and gitignore data/.
2026-05-11 14:52:09 +08:00
Jose Olarte III
d311b6a504 feat(fcm): add Firebase Admin SDK and messaging export
Wire firebase-admin with ADC or FIREBASE_SERVICE_ACCOUNT_JSON,
export messaging from src/services/firebase.ts, and load it at
server startup.
2026-05-11 14:47:05 +08:00
26 changed files with 6339 additions and 30 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# HTTP port (default: 3003)
PORT=3003
# 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

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules/
dist/
.env
*.log
data/
.DS_Store

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# ---- build stage: install everything, type-check + compile to JS ----
FROM node:22-alpine AS build
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
# ---- runtime stage: prod deps + compiled JS only, no tsx/esbuild ----
FROM node:22-alpine AS runtime
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 --frozen-lockfile
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=3003
EXPOSE 3003
CMD ["node", "dist/index.js"]

43
README.md Normal file
View 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:3003` (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 --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
docker run --env-file notify-wakeup-api.env -p 3003:3003 notify-wakeup-api
```
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: `3003`). |
| `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |

2311
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,30 @@
{
"name": "notification-wakeup-service",
"version": "0.1.0",
"version": "0.1.1",
"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",
"dotenv": "^16.6.1",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"express": "^5.2.1",
"firebase-admin": "^13.10.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"@types/express": "^5.0.6",
"@types/node": "^22.19.19",
"@types/cors": "^2.8.19",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
}
}

2
pkgx.yaml Normal file
View File

@@ -0,0 +1,2 @@
dependencies:
pnpm.io: ^11

2533
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

11
pnpm-workspace.yaml Normal file
View 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
View 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);
},
};

3
src/env.ts Normal file
View File

@@ -0,0 +1,3 @@
import { config } from "dotenv";
config();

View File

@@ -1,17 +1,31 @@
import "./env.js";
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;
const port = Number(process.env.PORT) || 3003;
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();

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

@@ -0,0 +1,117 @@
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;
};
};
/** 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
);
}
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,
});
}
}
export 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);
}

View File

@@ -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;
}

147
src/routes/debug.ts Normal file
View File

@@ -0,0 +1,147 @@
import express, { Router } from "express";
import { db } from "../db/fcmTokens.js";
import {
requireAuth,
requireAuthOrNotificationLocalTest,
} 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: express.Router = Router();
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", requireAuth, async (req, res) => {
const started = Date.now();
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const tokenParam = req.params.token;
const fcmToken = decodeURIComponent(
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
);
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", 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 { 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
);
});

View File

@@ -1,7 +1,191 @@
import { Router } from "express";
import express, { Router } from "express";
import { db } from "../db/fcmTokens.js";
import { requireAuthOrNotificationLocalTest } from "../middleware/auth.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js";
export const notificationsRouter = Router();
export const notificationsRouter: express.Router = 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);
}
}
);

View File

@@ -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 {

53
src/services/firebase.ts Normal file
View File

@@ -0,0 +1,53 @@
import admin from "firebase-admin";
import type { ServiceAccount } from "firebase-admin/app";
import type { Messaging } from "firebase-admin/messaging";
type ServiceAccountJson = ServiceAccount & { project_id?: string };
function serviceAccountProjectId(account: ServiceAccountJson): string | undefined {
if (typeof account.projectId === "string" && account.projectId.length > 0) {
return account.projectId;
}
if (typeof account.project_id === "string" && account.project_id.length > 0) {
return account.project_id;
}
return undefined;
}
function resolveCredential(): admin.credential.Credential {
const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
if (json !== undefined && json.trim() !== "") {
let account: ServiceAccountJson;
try {
account = JSON.parse(json) as ServiceAccountJson;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(
"[Firebase] FIREBASE_SERVICE_ACCOUNT_JSON parse failed:",
message
);
throw err;
}
const projectId = serviceAccountProjectId(account);
console.log(
"[Firebase] Credential: FIREBASE_SERVICE_ACCOUNT_JSON (parsed successfully)"
);
if (projectId !== undefined) {
console.log("[Firebase] project_id:", projectId);
} else {
console.log("[Firebase] project_id: (not found in service account JSON)");
}
return admin.credential.cert(account);
}
console.log("[Firebase] Credential: Application Default Credentials");
return admin.credential.applicationDefault();
}
if (!admin.apps.length) {
admin.initializeApp({
credential: resolveCredential(),
});
}
export const messaging: Messaging = admin.messaging();

View File

@@ -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
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 {};

17
src/util/formatElapsed.ts Normal file
View 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
View 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);
}

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 };
}