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)
|
# 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",...}
|
||||||
|
|||||||
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
|
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`). |
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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