Merge pull request 'endorser-authentication' (#2) from endorser-authentication into master

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-07-16 12:32:40 +00:00
6 changed files with 139 additions and 3 deletions

View File

@@ -1,6 +1,9 @@
# HTTP port (default: 3003) # HTTP port (default: 3003)
PORT=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). # Firebase Admin: inline service account JSON (one line).
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS). # If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...} # FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}

View File

@@ -6,9 +6,11 @@ A lightweight Express service that schedules and sends Firebase Cloud Messaging
cp .env.example .env 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 .` 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 ```bash
pnpm install pnpm install
pnpm run dev 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. 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 ## 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. | | `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
| `PORT` | HTTP port (default: `3003`). | | `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`). | | `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |

View File

@@ -1,3 +1,7 @@
import { config } from "dotenv"; import { config } from "dotenv";
config(); config();
/** Base URL for the Endorser API server. */
export const ENDORSER_URL =
process.env.ENDORSER_URL ?? "https://api.endorser.ch";

View File

@@ -1,4 +1,5 @@
import type { NextFunction, Request, Response } from "express"; import type { NextFunction, Request, Response } from "express";
import { checkAuth } from "../services/endorserClient.js";
import { decodeAndVerifyJwt } from "../vc/index.js"; import { decodeAndVerifyJwt } from "../vc/index.js";
export type AuthContext = { export type AuthContext = {
@@ -115,3 +116,54 @@ export async function requireAuthOrNotificationLocalTest(
} }
return requireAuth(req, res, next); 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<void> {
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,
});
}

View File

@@ -1,6 +1,9 @@
import express, { Router } from "express"; import express, { Router } from "express";
import { db } from "../db/fcmTokens.js"; 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 { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import { maskToken } from "../util/maskToken.js"; import { maskToken } from "../util/maskToken.js";
@@ -13,6 +16,7 @@ notificationsRouter.get("/", (_req, res) => {
notificationsRouter.post( notificationsRouter.post(
"/refresh", "/refresh",
requireAuthOrNotificationLocalTest, requireAuthOrNotificationLocalTest,
requireEndorserAuth,
async (req, res) => { async (req, res) => {
const started = Date.now(); const started = Date.now();
const userId = req.did; const userId = req.did;
@@ -87,6 +91,7 @@ notificationsRouter.post(
notificationsRouter.post( notificationsRouter.post(
"/register", "/register",
requireAuthOrNotificationLocalTest, requireAuthOrNotificationLocalTest,
requireEndorserAuth,
async (req, res) => { async (req, res) => {
const started = Date.now(); const started = Date.now();
const userId = req.did; const userId = req.did;

View File

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