From 0758c4192fb8834b262fb3a2c4f557b35330c3d6 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Fri, 10 Jul 2026 16:09:57 +0800 Subject: [PATCH 1/6] feat(config): add ENDORSER_URL for upcoming Endorser auth Expose the Endorser API base URL from the environment (defaulting to production) so later auth work can call it without hardcoding. --- .env.example | 3 +++ src/env.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.env.example b/.env.example index 96487c8..5cf6e15 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # HTTP port (default: 3003) PORT=3003 +# Endorser API base URL (default: https://api.endorser.ch) +# ENDORSER_URL=https://api.endorser.ch + # 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",...} diff --git a/src/env.ts b/src/env.ts index f4378ae..dc2d113 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,3 +1,7 @@ import { config } from "dotenv"; config(); + +/** Base URL for the Endorser API server. */ +export const ENDORSER_URL = + process.env.ENDORSER_URL ?? "https://api.endorser.ch"; From e5415280bd5e62b06ce03461c94ef0a2f9f80f43 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Fri, 10 Jul 2026 18:05:16 +0800 Subject: [PATCH 2/6] feat(endorser): add client helper to check JWT via rateLimits Introduce checkAuth so callers can verify Endorser acceptance of a JWT without dealing with HTTP details. --- src/services/endorserClient.ts | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/services/endorserClient.ts diff --git a/src/services/endorserClient.ts b/src/services/endorserClient.ts new file mode 100644 index 0000000..d87ab74 --- /dev/null +++ b/src/services/endorserClient.ts @@ -0,0 +1,44 @@ +import { ENDORSER_URL } from "../env.js"; +import { errorMessage } from "../util/formatElapsed.js"; + +const RATE_LIMITS_PATH = "/api/report/rateLimits"; + +function rateLimitsUrl(): string { + const base = ENDORSER_URL.replace(/\/+$/, ""); + return `${base}${RATE_LIMITS_PATH}`; +} + +/** + * Confirms a JWT is accepted by the Endorser server. + * + * Calls GET /api/report/rateLimits with the JWT as a Bearer token. + * Returns true on success, false on auth or request failure. + * Does not expose HTTP status or response bodies to callers. + */ +export async function checkAuth(jwt: string): Promise { + let response: Response; + try { + response = await fetch(rateLimitsUrl(), { + method: "GET", + headers: { + Authorization: `Bearer ${jwt}`, + }, + }); + } catch (err) { + console.error( + "[Endorser] Auth check request failed:", + errorMessage(err) + ); + return false; + } + + if (!response.ok) { + console.error( + "[Endorser] Auth check failed with status", + response.status + ); + return false; + } + + return true; +} From 764c89c071abebc6279ada40c41dc26967955ebb Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Fri, 10 Jul 2026 18:44:35 +0800 Subject: [PATCH 3/6] feat(auth): require Endorser verification on device registration Gate /notifications/register on Endorser JWT acceptance while leaving the local testMode bypass unchanged. --- src/routes/notifications.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index d95bf19..8c6d613 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,6 +1,7 @@ import express, { Router } from "express"; import { db } from "../db/fcmTokens.js"; import { requireAuthOrNotificationLocalTest } from "../middleware/auth.js"; +import { checkAuth } from "../services/endorserClient.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { maskToken } from "../util/maskToken.js"; @@ -95,6 +96,23 @@ notificationsRouter.post( return; } + // Local test bypass leaves req.jwt unset; only Endorser-check authenticated requests. + const jwt = req.jwt; + if (jwt !== undefined) { + const endorsed = await checkAuth(jwt); + if (!endorsed) { + console.log( + "[Register] Endorser verification failed in", + formatElapsedMs(Date.now() - started) + ); + res.status(401).json({ + success: false, + message: "Unauthorized", + }); + return; + } + } + if ( req.body !== null && typeof req.body === "object" && From f2f0732ef59c80ed938852a9ed8d74e335bdd715 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Fri, 10 Jul 2026 18:58:52 +0800 Subject: [PATCH 4/6] feat(auth): share Endorser verification across register and refresh Extract requireEndorserAuth middleware and apply it to /notifications/refresh so both endpoints use the same check while keeping the local test bypass. --- src/middleware/auth.ts | 29 +++++++++++++++++++++++++++++ src/routes/notifications.ts | 25 ++++++------------------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 919d099..c26f902 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,4 +1,5 @@ import type { NextFunction, Request, Response } from "express"; +import { checkAuth } from "../services/endorserClient.js"; import { decodeAndVerifyJwt } from "../vc/index.js"; export type AuthContext = { @@ -115,3 +116,31 @@ export async function requireAuthOrNotificationLocalTest( } return requireAuth(req, res, next); } + +/** + * When req.jwt is set (normal auth), require Endorser acceptance. + * Local test bypass leaves req.jwt unset and is allowed through unchanged. + */ +export async function requireEndorserAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + const jwt = req.jwt; + if (jwt === undefined) { + next(); + return; + } + + const endorsed = await checkAuth(jwt); + if (!endorsed) { + console.log("[Auth] Endorser verification failed"); + res.status(401).json({ + success: false, + message: "Unauthorized", + }); + return; + } + + next(); +} diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index 8c6d613..a2f3847 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,7 +1,9 @@ import express, { Router } from "express"; import { db } from "../db/fcmTokens.js"; -import { requireAuthOrNotificationLocalTest } from "../middleware/auth.js"; -import { checkAuth } from "../services/endorserClient.js"; +import { + requireAuthOrNotificationLocalTest, + requireEndorserAuth, +} from "../middleware/auth.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { maskToken } from "../util/maskToken.js"; @@ -14,6 +16,7 @@ notificationsRouter.get("/", (_req, res) => { notificationsRouter.post( "/refresh", requireAuthOrNotificationLocalTest, + requireEndorserAuth, async (req, res) => { const started = Date.now(); const userId = req.did; @@ -88,6 +91,7 @@ notificationsRouter.post( notificationsRouter.post( "/register", requireAuthOrNotificationLocalTest, + requireEndorserAuth, async (req, res) => { const started = Date.now(); const userId = req.did; @@ -96,23 +100,6 @@ notificationsRouter.post( return; } - // Local test bypass leaves req.jwt unset; only Endorser-check authenticated requests. - const jwt = req.jwt; - if (jwt !== undefined) { - const endorsed = await checkAuth(jwt); - if (!endorsed) { - console.log( - "[Register] Endorser verification failed in", - formatElapsedMs(Date.now() - started) - ); - res.status(401).json({ - success: false, - message: "Unauthorized", - }); - return; - } - } - if ( req.body !== null && typeof req.body === "object" && From 2343cece5cc7115f4ca92ce1fa152efc221e4a60 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Fri, 10 Jul 2026 19:06:40 +0800 Subject: [PATCH 5/6] fix(auth): distinguish Endorser unavailability from auth rejection Return 503 when Endorser cannot be reached and keep 401 for rejected JWTs, with clearer server-side diagnostics and generic client messages. --- src/middleware/auth.ts | 35 ++++++++++++++++++++++++++------ src/services/endorserClient.ts | 37 +++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index c26f902..db38442 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -132,15 +132,38 @@ export async function requireEndorserAuth( return; } - const endorsed = await checkAuth(jwt); - if (!endorsed) { - console.log("[Auth] Endorser verification failed"); - res.status(401).json({ + const result = await checkAuth(jwt); + if (result.ok) { + next(); + return; + } + + const errorTime = new Date().toISOString(); + const did = req.did ?? "(unknown)"; + + if (result.reason === "unavailable") { + console.log("[Auth] Endorser unavailable"); + console.error( + "[Auth] Endorser auth check unavailable at", + errorTime + ", did:", + did + ); + res.status(503).json({ success: false, - message: "Unauthorized", + message: + "Authentication service unavailable. See server logs at " + errorTime, }); return; } - next(); + console.log("[Auth] Endorser verification failed"); + console.error( + "[Auth] Endorser rejected JWT at", + errorTime + ", did:", + did + ); + res.status(401).json({ + success: false, + message: "Unauthorized. See server logs at " + errorTime, + }); } diff --git a/src/services/endorserClient.ts b/src/services/endorserClient.ts index d87ab74..2179bb0 100644 --- a/src/services/endorserClient.ts +++ b/src/services/endorserClient.ts @@ -3,6 +3,10 @@ import { errorMessage } from "../util/formatElapsed.js"; const RATE_LIMITS_PATH = "/api/report/rateLimits"; +export type EndorserAuthResult = + | { ok: true } + | { ok: false; reason: "unauthorized" | "unavailable" }; + function rateLimitsUrl(): string { const base = ENDORSER_URL.replace(/\/+$/, ""); return `${base}${RATE_LIMITS_PATH}`; @@ -12,13 +16,15 @@ function rateLimitsUrl(): string { * Confirms a JWT is accepted by the Endorser server. * * Calls GET /api/report/rateLimits with the JWT as a Bearer token. - * Returns true on success, false on auth or request failure. + * Distinguishes auth rejection from Endorser unavailability. * Does not expose HTTP status or response bodies to callers. */ -export async function checkAuth(jwt: string): Promise { +export async function checkAuth(jwt: string): Promise { + const url = rateLimitsUrl(); + let response: Response; try { - response = await fetch(rateLimitsUrl(), { + response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${jwt}`, @@ -26,19 +32,32 @@ export async function checkAuth(jwt: string): Promise { }); } catch (err) { console.error( - "[Endorser] Auth check request failed:", + "[Endorser] Auth check request failed for", + url + ":", errorMessage(err) ); - return false; + return { ok: false, reason: "unavailable" }; } - if (!response.ok) { + if (response.ok) { + return { ok: true }; + } + + // 5xx: Endorser is up but unhealthy; treat as unavailable. + if (response.status >= 500) { console.error( - "[Endorser] Auth check failed with status", + "[Endorser] Auth check unavailable for", + url + ", status", response.status ); - return false; + return { ok: false, reason: "unavailable" }; } - return true; + // 4xx: JWT rejected or user not registered on Endorser. + console.error( + "[Endorser] Auth check rejected for", + url + ", status", + response.status + ); + return { ok: false, reason: "unauthorized" }; } From 9634c3422eea95a2dd1713e6a826f0adb5af644b Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Fri, 10 Jul 2026 21:09:22 +0800 Subject: [PATCH 6/6] docs: document Endorser auth flow and ENDORSER_URL Describe when register/refresh verify with Endorser, keep the testMode bypass notes, and list ENDORSER_URL in the env table. --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 03cde7e..5c956a9 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ A lightweight Express service that schedules and sends Firebase Cloud Messaging cp .env.example .env ``` -Edit .env — set FIREBASE_SERVICE_ACCOUNT_JSON +Edit .env — set `FIREBASE_SERVICE_ACCOUNT_JSON`. Here is one way to generate the contents: `cat your-downloaded-key.json | jq -c .` +Optionally set `ENDORSER_URL` if you are not using the default production Endorser API (`https://api.endorser.ch`). + ```bash pnpm install pnpm run dev @@ -16,7 +18,13 @@ 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. +### Authentication + +`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT. + +**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. + +Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification during local development (this is separate from the `testMode` bypass above). ## Production @@ -40,4 +48,5 @@ Required environment variables: |---|---| | `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. | | `PORT` | HTTP port (default: `3003`). | +| `ENDORSER_URL` | Endorser API base URL used for auth checks on register/refresh (default: `https://api.endorser.ch`). | | `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |