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:
@@ -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",...}
|
||||
|
||||
13
README.md
13
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`). |
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
63
src/services/endorserClient.ts
Normal file
63
src/services/endorserClient.ts
Normal 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user