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/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`). | 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"; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 919d099..db38442 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,54 @@ 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 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: + "Authentication service unavailable. See server logs at " + errorTime, + }); + return; + } + + 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/routes/notifications.ts b/src/routes/notifications.ts index d95bf19..a2f3847 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,6 +1,9 @@ import express, { Router } from "express"; import { db } from "../db/fcmTokens.js"; -import { requireAuthOrNotificationLocalTest } from "../middleware/auth.js"; +import { + requireAuthOrNotificationLocalTest, + requireEndorserAuth, +} from "../middleware/auth.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { maskToken } from "../util/maskToken.js"; @@ -13,6 +16,7 @@ notificationsRouter.get("/", (_req, res) => { notificationsRouter.post( "/refresh", requireAuthOrNotificationLocalTest, + requireEndorserAuth, async (req, res) => { const started = Date.now(); const userId = req.did; @@ -87,6 +91,7 @@ notificationsRouter.post( notificationsRouter.post( "/register", requireAuthOrNotificationLocalTest, + requireEndorserAuth, async (req, res) => { const started = Date.now(); const userId = req.did; diff --git a/src/services/endorserClient.ts b/src/services/endorserClient.ts new file mode 100644 index 0000000..2179bb0 --- /dev/null +++ b/src/services/endorserClient.ts @@ -0,0 +1,63 @@ +import { ENDORSER_URL } from "../env.js"; +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}`; +} + +/** + * Confirms a JWT is accepted by the Endorser server. + * + * Calls GET /api/report/rateLimits with the JWT as a Bearer token. + * Distinguishes auth rejection from Endorser unavailability. + * Does not expose HTTP status or response bodies to callers. + */ +export async function checkAuth(jwt: string): Promise { + const url = rateLimitsUrl(); + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${jwt}`, + }, + }); + } catch (err) { + console.error( + "[Endorser] Auth check request failed for", + url + ":", + errorMessage(err) + ); + return { ok: false, reason: "unavailable" }; + } + + if (response.ok) { + return { ok: true }; + } + + // 5xx: Endorser is up but unhealthy; treat as unavailable. + if (response.status >= 500) { + console.error( + "[Endorser] Auth check unavailable for", + url + ", status", + response.status + ); + return { ok: false, reason: "unavailable" }; + } + + // 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" }; +}