@medi-car-bd/mcb-shared-auth 0.0.17 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/middlewares/authenticate.d.ts +11 -0
- package/dist/middlewares/authenticate.js +33 -4
- package/dist/utils/appError.d.ts +1 -1
- package/dist/utils/authProxy.d.ts +22 -0
- package/dist/utils/authProxy.js +84 -0
- package/dist/utils/cors.d.ts +27 -0
- package/dist/utils/cors.js +37 -0
- package/dist/utils/globalError.js +22 -5
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/limiter.d.ts +21 -0
- package/dist/utils/limiter.js +46 -4
- package/dist/utils/logger.js +6 -0
- package/dist/utils/ops.d.ts +38 -0
- package/dist/utils/ops.js +155 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -26,6 +26,16 @@ npm install @medi-car-bd/mcb-shared-auth
|
|
|
26
26
|
Requires **Express 4** as a peer dependency (`^4.18.2`). The package targets
|
|
27
27
|
CommonJS and ships type declarations.
|
|
28
28
|
|
|
29
|
+
## Operational helpers
|
|
30
|
+
|
|
31
|
+
Shared so every service behaves the same under an orchestrator:
|
|
32
|
+
|
|
33
|
+
- `validateEnv({ service, required, secrets })` exits at startup naming every missing variable, and in production rejects secrets shorter than 32 characters or obvious placeholders.
|
|
34
|
+
- `healthHandler(service)` is a liveness probe with no dependencies.
|
|
35
|
+
- `readyHandler(service, { database: () => prisma.$queryRaw\`SELECT 1\` })` returns 200 only when every check passes, otherwise 503. List only dependencies the service cannot work without; the response names failed checks but never the underlying error.
|
|
36
|
+
- `installGracefulShutdown(server, [cleanup, ...])` handles SIGTERM/SIGINT by draining the server, running the cleanups, then exiting, with a hard timeout.
|
|
37
|
+
- `verifyAccessToken(token)` is the single access-token check used by `authenticate` and by Socket.IO handshakes.
|
|
38
|
+
|
|
29
39
|
## Quick start
|
|
30
40
|
|
|
31
41
|
### Protect a route
|
|
@@ -3,4 +3,15 @@ import jwt from "jsonwebtoken";
|
|
|
3
3
|
export type AuthenticatedRequest = Request & {
|
|
4
4
|
user?: jwt.JwtPayload & Record<string, unknown>;
|
|
5
5
|
};
|
|
6
|
+
/**
|
|
7
|
+
* Verifies an access token and returns its claims, or throws.
|
|
8
|
+
*
|
|
9
|
+
* Shared by the HTTP middleware and by Socket.IO handshakes so that every
|
|
10
|
+
* entry point applies the same checks. The algorithm, audience and issuer are
|
|
11
|
+
* pinned to what the auth service signs with, so a token minted for another
|
|
12
|
+
* purpose is not accepted. Refresh tokens share the secret, audience and
|
|
13
|
+
* issuer; only `type` distinguishes them, and one must never act as an access
|
|
14
|
+
* token.
|
|
15
|
+
*/
|
|
16
|
+
export declare const verifyAccessToken: (token: string) => jwt.JwtPayload & Record<string, unknown>;
|
|
6
17
|
export declare const authenticate: (req: AuthenticatedRequest, _res: Response, next: NextFunction) => void;
|
|
@@ -3,10 +3,37 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.authenticate = void 0;
|
|
6
|
+
exports.authenticate = exports.verifyAccessToken = void 0;
|
|
7
7
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
8
8
|
const config_1 = require("../config");
|
|
9
9
|
const utils_1 = require("../utils");
|
|
10
|
+
/**
|
|
11
|
+
* Verifies an access token and returns its claims, or throws.
|
|
12
|
+
*
|
|
13
|
+
* Shared by the HTTP middleware and by Socket.IO handshakes so that every
|
|
14
|
+
* entry point applies the same checks. The algorithm, audience and issuer are
|
|
15
|
+
* pinned to what the auth service signs with, so a token minted for another
|
|
16
|
+
* purpose is not accepted. Refresh tokens share the secret, audience and
|
|
17
|
+
* issuer; only `type` distinguishes them, and one must never act as an access
|
|
18
|
+
* token.
|
|
19
|
+
*/
|
|
20
|
+
const verifyAccessToken = (token) => {
|
|
21
|
+
// Fail closed. Verifying against String(undefined) would accept tokens
|
|
22
|
+
// signed with the literal secret "undefined".
|
|
23
|
+
if (!config_1.JWT_SECRET) {
|
|
24
|
+
throw new utils_1.AppError("Authentication is not configured", 500);
|
|
25
|
+
}
|
|
26
|
+
const decoded = jsonwebtoken_1.default.verify(token, config_1.JWT_SECRET, {
|
|
27
|
+
algorithms: ["HS256"],
|
|
28
|
+
audience: "mcb-services",
|
|
29
|
+
issuer: "mcb-auth",
|
|
30
|
+
});
|
|
31
|
+
if (decoded.type !== "access") {
|
|
32
|
+
throw new utils_1.AppError("Invalid or expired token", 401);
|
|
33
|
+
}
|
|
34
|
+
return decoded;
|
|
35
|
+
};
|
|
36
|
+
exports.verifyAccessToken = verifyAccessToken;
|
|
10
37
|
const authenticate = (req, _res, next) => {
|
|
11
38
|
const header = req.headers.authorization;
|
|
12
39
|
const token = (header && header.startsWith("Bearer ") && header.split(" ")[1]) ||
|
|
@@ -15,11 +42,13 @@ const authenticate = (req, _res, next) => {
|
|
|
15
42
|
return next(new utils_1.AppError("Authentication required", 401));
|
|
16
43
|
}
|
|
17
44
|
try {
|
|
18
|
-
|
|
19
|
-
req.user = decoded;
|
|
45
|
+
req.user = (0, exports.verifyAccessToken)(token);
|
|
20
46
|
return next();
|
|
21
47
|
}
|
|
22
|
-
catch (
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof utils_1.AppError && error.statusCode === 500) {
|
|
50
|
+
return next(error);
|
|
51
|
+
}
|
|
23
52
|
return next(new utils_1.AppError("Invalid or expired token", 401));
|
|
24
53
|
}
|
|
25
54
|
};
|
package/dist/utils/appError.d.ts
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Handlers that let a client expose the auth endpoints under its own prefix
|
|
4
|
+
* (/api/v1/billing/auth/login and so on), so its UI only ever talks to one
|
|
5
|
+
* service. They hold no auth logic: each request goes to mcb-auth-service and
|
|
6
|
+
* its response comes back unchanged, including the status, body and any
|
|
7
|
+
* Set-Cookie headers. Only the auth service issues tokens.
|
|
8
|
+
*
|
|
9
|
+
* The caller's Authorization header and cookies are forwarded as they are, and
|
|
10
|
+
* if the auth service is unreachable or too slow the caller gets a plain 502;
|
|
11
|
+
* the underlying error is logged, never returned, because it can name internal
|
|
12
|
+
* hosts.
|
|
13
|
+
*/
|
|
14
|
+
export declare const authProxy: (authServiceUrl: string, options?: {
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}) => {
|
|
17
|
+
register: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
|
18
|
+
login: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
|
19
|
+
refresh: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
|
20
|
+
logout: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
|
21
|
+
me: (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
|
22
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.authProxy = void 0;
|
|
13
|
+
const appError_1 = require("./appError");
|
|
14
|
+
const logger_1 = require("./logger");
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
16
|
+
/** Request headers the auth service needs from the original caller. */
|
|
17
|
+
const FORWARDED_HEADERS = [
|
|
18
|
+
"authorization",
|
|
19
|
+
"cookie",
|
|
20
|
+
"user-agent",
|
|
21
|
+
"x-correlation-id",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Handlers that let a client expose the auth endpoints under its own prefix
|
|
25
|
+
* (/api/v1/billing/auth/login and so on), so its UI only ever talks to one
|
|
26
|
+
* service. They hold no auth logic: each request goes to mcb-auth-service and
|
|
27
|
+
* its response comes back unchanged, including the status, body and any
|
|
28
|
+
* Set-Cookie headers. Only the auth service issues tokens.
|
|
29
|
+
*
|
|
30
|
+
* The caller's Authorization header and cookies are forwarded as they are, and
|
|
31
|
+
* if the auth service is unreachable or too slow the caller gets a plain 502;
|
|
32
|
+
* the underlying error is logged, never returned, because it can name internal
|
|
33
|
+
* hosts.
|
|
34
|
+
*/
|
|
35
|
+
const authProxy = (authServiceUrl, options = {}) => {
|
|
36
|
+
var _a;
|
|
37
|
+
const base = authServiceUrl.replace(/\/+$/, "");
|
|
38
|
+
const timeoutMs = (_a = options.timeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_TIMEOUT_MS;
|
|
39
|
+
const forward = (endpoint, method) => (req, res, next) => __awaiter(void 0, void 0, void 0, function* () {
|
|
40
|
+
var _a;
|
|
41
|
+
const headers = {
|
|
42
|
+
"content-type": "application/json",
|
|
43
|
+
};
|
|
44
|
+
for (const name of FORWARDED_HEADERS) {
|
|
45
|
+
const value = req.headers[name];
|
|
46
|
+
if (typeof value === "string")
|
|
47
|
+
headers[name] = value;
|
|
48
|
+
}
|
|
49
|
+
// The real caller, so the auth service's per-IP limit does not count every
|
|
50
|
+
// user of this service as one client.
|
|
51
|
+
if (req.ip)
|
|
52
|
+
headers["x-forwarded-for"] = req.ip;
|
|
53
|
+
let upstream;
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
upstream = yield fetch(`${base}/api/v1/auth/auth/${endpoint}`, {
|
|
57
|
+
method,
|
|
58
|
+
headers,
|
|
59
|
+
body: method === "POST" ? JSON.stringify((_a = req.body) !== null && _a !== void 0 ? _a : {}) : undefined,
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
61
|
+
});
|
|
62
|
+
text = yield upstream.text();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
logger_1.logger.error(`Auth service request failed (${endpoint}): ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
return next(new appError_1.AppError("Auth service unavailable", 502));
|
|
67
|
+
}
|
|
68
|
+
const contentType = upstream.headers.get("content-type");
|
|
69
|
+
if (contentType)
|
|
70
|
+
res.setHeader("content-type", contentType);
|
|
71
|
+
const cookies = upstream.headers.getSetCookie();
|
|
72
|
+
if (cookies.length > 0)
|
|
73
|
+
res.setHeader("set-cookie", cookies);
|
|
74
|
+
return res.status(upstream.status).send(text);
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
register: forward("register", "POST"),
|
|
78
|
+
login: forward("login", "POST"),
|
|
79
|
+
refresh: forward("refresh", "POST"),
|
|
80
|
+
logout: forward("logout", "POST"),
|
|
81
|
+
me: forward("me", "GET"),
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
exports.authProxy = authProxy;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser access rules shared by every service, so each UI can call its own
|
|
3
|
+
* client directly (for example the billing UI on :3001 calling the billing
|
|
4
|
+
* client on :8081) and nothing else can.
|
|
5
|
+
*
|
|
6
|
+
* Note that `cors({ origin: ["*"] })` does NOT allow everything: inside a list
|
|
7
|
+
* the cors package treats "*" as a literal origin, which matches no browser.
|
|
8
|
+
* That is what every service had before, and why direct UI calls failed.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Reads a comma-separated origin list (normally the CORS_ORIGINS variable),
|
|
12
|
+
* falling back to `defaults` when it is unset or empty. Origins are compared
|
|
13
|
+
* exactly, so they must include scheme and port and have no trailing slash.
|
|
14
|
+
*/
|
|
15
|
+
export declare const parseOrigins: (raw: string | undefined, defaults?: string[]) => string[];
|
|
16
|
+
/**
|
|
17
|
+
* Options for the `cors` middleware (and the same shape works for Socket.IO's
|
|
18
|
+
* `cors` setting). Requests without an Origin header
|
|
19
|
+
* (server-to-server calls, curl, health checks) are unaffected; a browser
|
|
20
|
+
* request from an origin not in the list gets no CORS headers and is blocked
|
|
21
|
+
* by the browser.
|
|
22
|
+
*/
|
|
23
|
+
export type BrowserAccess = {
|
|
24
|
+
origin: string[] | false;
|
|
25
|
+
credentials: boolean;
|
|
26
|
+
};
|
|
27
|
+
export declare const corsOptions: (origins: string[]) => BrowserAccess;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.corsOptions = exports.parseOrigins = void 0;
|
|
4
|
+
const logger_1 = require("./logger");
|
|
5
|
+
/**
|
|
6
|
+
* Browser access rules shared by every service, so each UI can call its own
|
|
7
|
+
* client directly (for example the billing UI on :3001 calling the billing
|
|
8
|
+
* client on :8081) and nothing else can.
|
|
9
|
+
*
|
|
10
|
+
* Note that `cors({ origin: ["*"] })` does NOT allow everything: inside a list
|
|
11
|
+
* the cors package treats "*" as a literal origin, which matches no browser.
|
|
12
|
+
* That is what every service had before, and why direct UI calls failed.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Reads a comma-separated origin list (normally the CORS_ORIGINS variable),
|
|
16
|
+
* falling back to `defaults` when it is unset or empty. Origins are compared
|
|
17
|
+
* exactly, so they must include scheme and port and have no trailing slash.
|
|
18
|
+
*/
|
|
19
|
+
const parseOrigins = (raw, defaults = []) => {
|
|
20
|
+
const listed = (raw !== null && raw !== void 0 ? raw : "")
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((origin) => origin.trim().replace(/\/+$/, ""))
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
const origins = listed.length > 0 ? listed : defaults;
|
|
25
|
+
if (origins.includes("*")) {
|
|
26
|
+
// A wildcard cannot be combined with credentials, and in production it
|
|
27
|
+
// would let any website call the API from a signed-in user's browser.
|
|
28
|
+
logger_1.logger.warn('CORS_ORIGINS contains "*", which is ignored. List the UI origins explicitly.');
|
|
29
|
+
}
|
|
30
|
+
return origins.filter((origin) => origin !== "*");
|
|
31
|
+
};
|
|
32
|
+
exports.parseOrigins = parseOrigins;
|
|
33
|
+
const corsOptions = (origins) => ({
|
|
34
|
+
origin: origins.length > 0 ? origins : false,
|
|
35
|
+
credentials: true,
|
|
36
|
+
});
|
|
37
|
+
exports.corsOptions = corsOptions;
|
|
@@ -29,6 +29,25 @@ const errorHandler = (err, req, res, _next) => {
|
|
|
29
29
|
let statusCode = err.statusCode || 500;
|
|
30
30
|
const status = err.status || "error";
|
|
31
31
|
let { message } = err;
|
|
32
|
+
// Prisma rejecting the request itself is the caller's fault, not a server
|
|
33
|
+
// fault: unknown or mistyped fields, a reference to a missing record, or a
|
|
34
|
+
// record that no longer exists. Without this they all surfaced as 500s.
|
|
35
|
+
if (err.name === "PrismaClientValidationError") {
|
|
36
|
+
statusCode = 400;
|
|
37
|
+
message = "Invalid request: unknown or invalid fields";
|
|
38
|
+
}
|
|
39
|
+
else if (err.code === "P2002") {
|
|
40
|
+
statusCode = 409;
|
|
41
|
+
message = "A record with this value already exists";
|
|
42
|
+
}
|
|
43
|
+
else if (err.code === "P2003") {
|
|
44
|
+
statusCode = 400;
|
|
45
|
+
message = "Invalid request: it refers to a record that does not exist";
|
|
46
|
+
}
|
|
47
|
+
else if (err.code === "P2025") {
|
|
48
|
+
statusCode = 404;
|
|
49
|
+
message = "Not found";
|
|
50
|
+
}
|
|
32
51
|
if (err.code === 11000) {
|
|
33
52
|
statusCode = 400;
|
|
34
53
|
const value = ((_b = (_a = err.errmsg) === null || _a === void 0 ? void 0 : _a.match(/(["'])(?:\\.|[^\\])*?\1/)) === null || _b === void 0 ? void 0 : _b[0]) || "value";
|
|
@@ -69,11 +88,9 @@ const errorHandler = (err, req, res, _next) => {
|
|
|
69
88
|
default:
|
|
70
89
|
break;
|
|
71
90
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
stack: err.stack,
|
|
76
|
-
});
|
|
91
|
+
// Stack traces and internal error text are a disclosure risk in production.
|
|
92
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
93
|
+
res.status(statusCode).json(Object.assign({ state: status, message: isProduction && statusCode >= 500 ? "Internal server error" : message }, (isProduction ? {} : { stack: err.stack })));
|
|
77
94
|
_1.logger.error(`${statusCode} - ${status} - ${req.originalUrl} - ${req.method} - ${req.ip} - ${correlationId} - ${err.message} - ${err.stack} `);
|
|
78
95
|
};
|
|
79
96
|
exports.errorHandler = errorHandler;
|
package/dist/utils/index.d.ts
CHANGED
package/dist/utils/index.js
CHANGED
|
@@ -27,3 +27,6 @@ __exportStar(require("./rotateRefreshToken"), exports);
|
|
|
27
27
|
__exportStar(require("./generateAccessToken"), exports);
|
|
28
28
|
__exportStar(require("./hashToken"), exports);
|
|
29
29
|
__exportStar(require("./generateTokenId"), exports);
|
|
30
|
+
__exportStar(require("./ops"), exports);
|
|
31
|
+
__exportStar(require("./cors"), exports);
|
|
32
|
+
__exportStar(require("./authProxy"), exports);
|
package/dist/utils/limiter.d.ts
CHANGED
|
@@ -1 +1,22 @@
|
|
|
1
|
+
import "../config";
|
|
2
|
+
/**
|
|
3
|
+
* Per-caller request limit for a service. It counts each client address, so a
|
|
4
|
+
* service behind the gateway must also trust the gateway (see
|
|
5
|
+
* trustProxySetting), or every user counts as one caller.
|
|
6
|
+
*
|
|
7
|
+
* RATE_LIMIT_MAX and RATE_LIMIT_WINDOW_MS override the defaults. The default
|
|
8
|
+
* matches the gateway's per-service limit: field apps on mobile networks share
|
|
9
|
+
* addresses, so a low ceiling throttles real users rather than attackers.
|
|
10
|
+
*/
|
|
1
11
|
export declare const limiter: import("express-rate-limit").RateLimitRequestHandler;
|
|
12
|
+
/**
|
|
13
|
+
* The value for Express's "trust proxy": which directly connected addresses may
|
|
14
|
+
* state the real client through X-Forwarded-For. By default that is this
|
|
15
|
+
* machine and private networks, where the gateway and the other services run.
|
|
16
|
+
* A caller connecting from a public address is not trusted, so it cannot fake
|
|
17
|
+
* its address with that header.
|
|
18
|
+
*
|
|
19
|
+
* TRUST_PROXY overrides it: a number of hops, "true", "false", or a
|
|
20
|
+
* comma-separated list of addresses or names such as "loopback, uniquelocal".
|
|
21
|
+
*/
|
|
22
|
+
export declare const trustProxySetting: (raw?: string | undefined) => string | number | boolean;
|
package/dist/utils/limiter.js
CHANGED
|
@@ -3,10 +3,52 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.limiter = void 0;
|
|
6
|
+
exports.trustProxySetting = exports.limiter = void 0;
|
|
7
7
|
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
|
8
|
+
require("../config");
|
|
9
|
+
const DEFAULT_MAX = 600;
|
|
10
|
+
const DEFAULT_WINDOW_MS = 15 * 60 * 1000;
|
|
11
|
+
const positive = (raw, fallback) => {
|
|
12
|
+
const value = Number(raw);
|
|
13
|
+
return raw && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Per-caller request limit for a service. It counts each client address, so a
|
|
17
|
+
* service behind the gateway must also trust the gateway (see
|
|
18
|
+
* trustProxySetting), or every user counts as one caller.
|
|
19
|
+
*
|
|
20
|
+
* RATE_LIMIT_MAX and RATE_LIMIT_WINDOW_MS override the defaults. The default
|
|
21
|
+
* matches the gateway's per-service limit: field apps on mobile networks share
|
|
22
|
+
* addresses, so a low ceiling throttles real users rather than attackers.
|
|
23
|
+
*/
|
|
8
24
|
exports.limiter = (0, express_rate_limit_1.default)({
|
|
9
|
-
max:
|
|
10
|
-
windowMs:
|
|
11
|
-
|
|
25
|
+
max: positive(process.env.RATE_LIMIT_MAX, DEFAULT_MAX),
|
|
26
|
+
windowMs: positive(process.env.RATE_LIMIT_WINDOW_MS, DEFAULT_WINDOW_MS),
|
|
27
|
+
standardHeaders: true,
|
|
28
|
+
legacyHeaders: false,
|
|
29
|
+
message: {
|
|
30
|
+
state: "fail",
|
|
31
|
+
message: "Too many requests, please try again later.",
|
|
32
|
+
},
|
|
12
33
|
});
|
|
34
|
+
/**
|
|
35
|
+
* The value for Express's "trust proxy": which directly connected addresses may
|
|
36
|
+
* state the real client through X-Forwarded-For. By default that is this
|
|
37
|
+
* machine and private networks, where the gateway and the other services run.
|
|
38
|
+
* A caller connecting from a public address is not trusted, so it cannot fake
|
|
39
|
+
* its address with that header.
|
|
40
|
+
*
|
|
41
|
+
* TRUST_PROXY overrides it: a number of hops, "true", "false", or a
|
|
42
|
+
* comma-separated list of addresses or names such as "loopback, uniquelocal".
|
|
43
|
+
*/
|
|
44
|
+
const trustProxySetting = (raw = process.env.TRUST_PROXY) => {
|
|
45
|
+
const value = raw === null || raw === void 0 ? void 0 : raw.trim();
|
|
46
|
+
if (!value)
|
|
47
|
+
return "loopback, uniquelocal";
|
|
48
|
+
if (value === "true")
|
|
49
|
+
return true;
|
|
50
|
+
if (value === "false")
|
|
51
|
+
return false;
|
|
52
|
+
return /^\d+$/.test(value) ? Number(value) : value;
|
|
53
|
+
};
|
|
54
|
+
exports.trustProxySetting = trustProxySetting;
|
package/dist/utils/logger.js
CHANGED
|
@@ -28,6 +28,12 @@ exports.logger = (0, winston_1.createLogger)({
|
|
|
28
28
|
}),
|
|
29
29
|
],
|
|
30
30
|
});
|
|
31
|
+
// Containers are expected to log to stdout: files inside a container are lost
|
|
32
|
+
// when it is replaced and invisible to `docker logs` and log shippers. The
|
|
33
|
+
// files above are kept for hosts that are not containerised.
|
|
34
|
+
if (config_1.NODE_ENV === "production") {
|
|
35
|
+
exports.logger.add(new winston_1.transports.Console({ format: winston_1.format.json() }));
|
|
36
|
+
}
|
|
31
37
|
if (config_1.NODE_ENV !== "production") {
|
|
32
38
|
exports.logger.add(new winston_1.transports.Console({
|
|
33
39
|
format: winston_1.format.combine(winston_1.format.colorize(), winston_1.format.timestamp({
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Server } from "http";
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
export type ValidateEnvOptions = {
|
|
4
|
+
service: string;
|
|
5
|
+
/** Variables that must be set and non-empty. */
|
|
6
|
+
required: string[];
|
|
7
|
+
/** Subset of variables holding secrets; checked for strength in production. */
|
|
8
|
+
secrets?: string[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Returns a list of problems with the process environment, empty when it is
|
|
12
|
+
* fine. Pure, so it can be tested without exiting the process.
|
|
13
|
+
*/
|
|
14
|
+
export declare const findEnvProblems: ({ required, secrets }: ValidateEnvOptions, env?: NodeJS.ProcessEnv) => string[];
|
|
15
|
+
/**
|
|
16
|
+
* Fails fast at startup. A service that boots with a missing secret otherwise
|
|
17
|
+
* looks healthy and then fails on every request, which is much harder to spot.
|
|
18
|
+
*/
|
|
19
|
+
export declare const validateEnv: (options: ValidateEnvOptions) => void;
|
|
20
|
+
/** Liveness: the process is up and the event loop is responding. No dependencies. */
|
|
21
|
+
export declare const healthHandler: (service: string) => (_req: Request, res: Response) => void;
|
|
22
|
+
export type ReadinessChecks = Record<string, () => Promise<unknown>>;
|
|
23
|
+
/**
|
|
24
|
+
* Readiness: 200 only when every check passes, otherwise 503. Only list
|
|
25
|
+
* dependencies the service cannot work without (its database). Optional ones
|
|
26
|
+
* such as Redis or Kafka must not appear here, or an outage of a nice-to-have
|
|
27
|
+
* would take the service out of rotation.
|
|
28
|
+
*
|
|
29
|
+
* The body names which checks failed but never the underlying error, which can
|
|
30
|
+
* contain hostnames and credentials.
|
|
31
|
+
*/
|
|
32
|
+
export declare const readyHandler: (service: string, checks: ReadinessChecks, timeoutMs?: number) => (_req: Request, res: Response) => Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Stops accepting connections on SIGTERM/SIGINT, lets in-flight requests
|
|
35
|
+
* finish, runs the cleanups (close the database, etc.), then exits. A hard
|
|
36
|
+
* timeout guarantees the process cannot hang a rolling deploy.
|
|
37
|
+
*/
|
|
38
|
+
export declare const installGracefulShutdown: (server: Server, cleanups?: Array<() => Promise<unknown> | unknown>, timeoutMs?: number) => void;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.installGracefulShutdown = exports.readyHandler = exports.healthHandler = exports.validateEnv = exports.findEnvProblems = void 0;
|
|
13
|
+
const logger_1 = require("./logger");
|
|
14
|
+
/**
|
|
15
|
+
* Operational helpers shared by every service: startup env validation,
|
|
16
|
+
* liveness and readiness handlers, and graceful shutdown. They live here so the
|
|
17
|
+
* services behave identically under an orchestrator instead of each growing its
|
|
18
|
+
* own slightly different copy.
|
|
19
|
+
*/
|
|
20
|
+
const PLACEHOLDER_SECRETS = new Set([
|
|
21
|
+
"secret",
|
|
22
|
+
"changeme",
|
|
23
|
+
"change-me",
|
|
24
|
+
"change_me",
|
|
25
|
+
"password",
|
|
26
|
+
"undefined",
|
|
27
|
+
"jwt_secret",
|
|
28
|
+
]);
|
|
29
|
+
const MIN_PRODUCTION_SECRET_LENGTH = 32;
|
|
30
|
+
/**
|
|
31
|
+
* Returns a list of problems with the process environment, empty when it is
|
|
32
|
+
* fine. Pure, so it can be tested without exiting the process.
|
|
33
|
+
*/
|
|
34
|
+
const findEnvProblems = ({ required, secrets = [] }, env = process.env) => {
|
|
35
|
+
var _a, _b, _c;
|
|
36
|
+
const problems = [];
|
|
37
|
+
for (const name of required) {
|
|
38
|
+
if (!((_a = env[name]) === null || _a === void 0 ? void 0 : _a.trim()))
|
|
39
|
+
problems.push(`${name} is not set`);
|
|
40
|
+
}
|
|
41
|
+
const port = (_b = env.PORT) === null || _b === void 0 ? void 0 : _b.trim();
|
|
42
|
+
if (port && !/^\d+$/.test(port))
|
|
43
|
+
problems.push("PORT must be a number (it is not, so the server would bind to a random port)");
|
|
44
|
+
if (env.NODE_ENV === "production") {
|
|
45
|
+
for (const name of secrets) {
|
|
46
|
+
const value = (_c = env[name]) === null || _c === void 0 ? void 0 : _c.trim();
|
|
47
|
+
if (!value)
|
|
48
|
+
continue; // already reported as missing when required
|
|
49
|
+
if (value.length < MIN_PRODUCTION_SECRET_LENGTH ||
|
|
50
|
+
PLACEHOLDER_SECRETS.has(value.toLowerCase())) {
|
|
51
|
+
problems.push(`${name} is too weak for production (use at least ${MIN_PRODUCTION_SECRET_LENGTH} random characters)`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return problems;
|
|
56
|
+
};
|
|
57
|
+
exports.findEnvProblems = findEnvProblems;
|
|
58
|
+
/**
|
|
59
|
+
* Fails fast at startup. A service that boots with a missing secret otherwise
|
|
60
|
+
* looks healthy and then fails on every request, which is much harder to spot.
|
|
61
|
+
*/
|
|
62
|
+
const validateEnv = (options) => {
|
|
63
|
+
const problems = (0, exports.findEnvProblems)(options);
|
|
64
|
+
if (problems.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
const message = `${options.service} cannot start:\n${problems
|
|
67
|
+
.map((p) => ` - ${p}`)
|
|
68
|
+
.join("\n")}`;
|
|
69
|
+
logger_1.logger.error(message);
|
|
70
|
+
console.error(message);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
};
|
|
73
|
+
exports.validateEnv = validateEnv;
|
|
74
|
+
/** Liveness: the process is up and the event loop is responding. No dependencies. */
|
|
75
|
+
const healthHandler = (service) => (_req, res) => {
|
|
76
|
+
res.status(200).json({
|
|
77
|
+
status: "ok",
|
|
78
|
+
service,
|
|
79
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
exports.healthHandler = healthHandler;
|
|
83
|
+
const withTimeout = (work, ms) => new Promise((resolve, reject) => {
|
|
84
|
+
const timer = setTimeout(() => reject(new Error("timeout")), ms);
|
|
85
|
+
work.then((value) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
resolve(value);
|
|
88
|
+
}, (error) => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
reject(error);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Readiness: 200 only when every check passes, otherwise 503. Only list
|
|
95
|
+
* dependencies the service cannot work without (its database). Optional ones
|
|
96
|
+
* such as Redis or Kafka must not appear here, or an outage of a nice-to-have
|
|
97
|
+
* would take the service out of rotation.
|
|
98
|
+
*
|
|
99
|
+
* The body names which checks failed but never the underlying error, which can
|
|
100
|
+
* contain hostnames and credentials.
|
|
101
|
+
*/
|
|
102
|
+
const readyHandler = (service, checks, timeoutMs = 2000) => (_req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
103
|
+
const results = {};
|
|
104
|
+
yield Promise.all(Object.entries(checks).map((_a) => __awaiter(void 0, [_a], void 0, function* ([name, check]) {
|
|
105
|
+
try {
|
|
106
|
+
yield withTimeout(check(), timeoutMs);
|
|
107
|
+
results[name] = "up";
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
results[name] = "down";
|
|
111
|
+
logger_1.logger.warn(`${service} readiness check "${name}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
112
|
+
}
|
|
113
|
+
})));
|
|
114
|
+
const ready = Object.values(results).every((r) => r === "up");
|
|
115
|
+
res.status(ready ? 200 : 503).json({
|
|
116
|
+
status: ready ? "ready" : "unavailable",
|
|
117
|
+
service,
|
|
118
|
+
checks: results,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
exports.readyHandler = readyHandler;
|
|
122
|
+
/**
|
|
123
|
+
* Stops accepting connections on SIGTERM/SIGINT, lets in-flight requests
|
|
124
|
+
* finish, runs the cleanups (close the database, etc.), then exits. A hard
|
|
125
|
+
* timeout guarantees the process cannot hang a rolling deploy.
|
|
126
|
+
*/
|
|
127
|
+
const installGracefulShutdown = (server, cleanups = [], timeoutMs = 10000) => {
|
|
128
|
+
let shuttingDown = false;
|
|
129
|
+
const shutdown = (signal) => {
|
|
130
|
+
var _a;
|
|
131
|
+
if (shuttingDown)
|
|
132
|
+
return;
|
|
133
|
+
shuttingDown = true;
|
|
134
|
+
logger_1.logger.info(`Received ${signal}, shutting down`);
|
|
135
|
+
const force = setTimeout(() => {
|
|
136
|
+
logger_1.logger.error("Graceful shutdown timed out, forcing exit");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
force.unref();
|
|
140
|
+
server.close(() => {
|
|
141
|
+
void Promise.allSettled(cleanups.map((fn) => Promise.resolve().then(fn))).then((results) => {
|
|
142
|
+
results.forEach((r) => {
|
|
143
|
+
if (r.status === "rejected")
|
|
144
|
+
logger_1.logger.warn(`Shutdown cleanup failed: ${String(r.reason)}`);
|
|
145
|
+
});
|
|
146
|
+
process.exit(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// Idle keep-alive connections would otherwise hold server.close() open.
|
|
150
|
+
(_a = server.closeIdleConnections) === null || _a === void 0 ? void 0 : _a.call(server);
|
|
151
|
+
};
|
|
152
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
153
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
154
|
+
};
|
|
155
|
+
exports.installGracefulShutdown = installGracefulShutdown;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medi-car-bd/mcb-shared-auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"description": "Shared authentication module for MCB applications",
|
|
5
5
|
"packageManager": "pnpm@10.18.0",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
|
|
18
18
|
"lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
|
|
19
19
|
"format": "prettier --write .",
|
|
20
|
-
"format:check": "prettier --check ."
|
|
20
|
+
"format:check": "prettier --check .",
|
|
21
|
+
"test": "tsc -p tsconfig.json && JWT_SECRET=unit-test-secret node --test dist/test/*.test.js"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [
|
|
23
24
|
"jwt",
|
|
@@ -55,7 +56,8 @@
|
|
|
55
56
|
"express": "^4.18.2"
|
|
56
57
|
},
|
|
57
58
|
"files": [
|
|
58
|
-
"dist"
|
|
59
|
+
"dist",
|
|
60
|
+
"!dist/test"
|
|
59
61
|
],
|
|
60
62
|
"lint-staged": {
|
|
61
63
|
"*.{ts,tsx,js,jsx}": [
|