@discover-cloud/shared 1.2.2 → 1.2.4
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/dist/jwt/index.d.ts +1 -0
- package/dist/jwt/index.js +1 -0
- package/dist/jwt/machine-token-client.d.ts +12 -0
- package/dist/jwt/machine-token-client.js +55 -0
- package/dist/middleware/authorize.middleware.js +6 -4
- package/dist/middleware/index.d.ts +2 -0
- package/dist/middleware/index.js +2 -0
- package/dist/middleware/require-auth.middleware.d.ts +86 -0
- package/dist/middleware/require-auth.middleware.js +163 -0
- package/dist/middleware/require-internal.middleware.d.ts +18 -0
- package/dist/middleware/require-internal.middleware.js +175 -0
- package/dist/types/express.types.d.ts +6 -1
- package/package.json +1 -1
package/dist/jwt/index.d.ts
CHANGED
package/dist/jwt/index.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ILogger } from "../utils";
|
|
2
|
+
export declare class MachineTokenClient {
|
|
3
|
+
private readonly gatewayUrl;
|
|
4
|
+
private readonly serviceId;
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private cache;
|
|
7
|
+
private fetchPromise;
|
|
8
|
+
constructor(gatewayUrl: string, serviceId: string, logger?: ILogger);
|
|
9
|
+
getToken(requestId?: string): Promise<string>;
|
|
10
|
+
private isValid;
|
|
11
|
+
private fetchToken;
|
|
12
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MachineTokenClient = void 0;
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const REFRESH_BUFFER_S = 10;
|
|
6
|
+
class MachineTokenClient {
|
|
7
|
+
constructor(gatewayUrl, serviceId, logger = utils_1.noopLogger) {
|
|
8
|
+
this.gatewayUrl = gatewayUrl;
|
|
9
|
+
this.serviceId = serviceId;
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
this.cache = null;
|
|
12
|
+
this.fetchPromise = null;
|
|
13
|
+
}
|
|
14
|
+
async getToken(requestId) {
|
|
15
|
+
if (this.isValid()) {
|
|
16
|
+
return this.cache.token;
|
|
17
|
+
}
|
|
18
|
+
if (!this.fetchPromise) {
|
|
19
|
+
this.fetchPromise = this.fetchToken(requestId).finally(() => {
|
|
20
|
+
this.fetchPromise = null;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return this.fetchPromise;
|
|
24
|
+
}
|
|
25
|
+
isValid() {
|
|
26
|
+
if (!this.cache)
|
|
27
|
+
return false;
|
|
28
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
29
|
+
return this.cache.expiresAt - REFRESH_BUFFER_S > nowS;
|
|
30
|
+
}
|
|
31
|
+
async fetchToken(requestId) {
|
|
32
|
+
const url = `${this.gatewayUrl}/internal/machine-token`;
|
|
33
|
+
this.logger.debug({ serviceId: this.serviceId, requestId }, "Fetching machine token");
|
|
34
|
+
const response = await fetch(url, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
"content-type": "application/json",
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({ serviceId: this.serviceId, requestId }),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const text = await response.text().catch(() => "(unreadable)");
|
|
43
|
+
throw new Error(`MachineTokenClient: gateway returned ${response.status} fetching machine token: ${text}`);
|
|
44
|
+
}
|
|
45
|
+
const body = (await response.json());
|
|
46
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
47
|
+
this.cache = {
|
|
48
|
+
token: body.data.token,
|
|
49
|
+
expiresAt: nowS + body.data.expiresIn,
|
|
50
|
+
};
|
|
51
|
+
this.logger.info({ serviceId: this.serviceId, expiresAt: this.cache.expiresAt }, "Machine token cached");
|
|
52
|
+
return this.cache.token;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.MachineTokenClient = MachineTokenClient;
|
|
@@ -58,11 +58,12 @@ exports.authorize = authorize;
|
|
|
58
58
|
*/
|
|
59
59
|
const authorizeAny = (...permissions) => {
|
|
60
60
|
return (req, res, next) => {
|
|
61
|
-
|
|
61
|
+
const ctx = req.accessContext;
|
|
62
|
+
if (!ctx) {
|
|
62
63
|
(0, utils_1.failure)(res, req, "Unauthorized: No access context", "UNAUTHORIZED", 401);
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
|
-
const hasAny = permissions.some((p) => internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(
|
|
66
|
+
const hasAny = permissions.some((p) => internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(ctx, p));
|
|
66
67
|
if (!hasAny) {
|
|
67
68
|
(0, utils_1.failure)(res, req, `Forbidden: Missing one of [${permissions.join(", ")}]`, "FORBIDDEN", 403);
|
|
68
69
|
return;
|
|
@@ -87,11 +88,12 @@ exports.authorizeAny = authorizeAny;
|
|
|
87
88
|
*/
|
|
88
89
|
const authorizeAll = (...permissions) => {
|
|
89
90
|
return (req, res, next) => {
|
|
90
|
-
|
|
91
|
+
const ctx = req.accessContext;
|
|
92
|
+
if (!ctx) {
|
|
91
93
|
(0, utils_1.failure)(res, req, "Unauthorized: No access context", "UNAUTHORIZED", 401);
|
|
92
94
|
return;
|
|
93
95
|
}
|
|
94
|
-
const missingPermissions = permissions.filter((p) => !internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(
|
|
96
|
+
const missingPermissions = permissions.filter((p) => !internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(ctx, p));
|
|
95
97
|
if (missingPermissions.length > 0) {
|
|
96
98
|
(0, utils_1.failure)(res, req, `Forbidden: Missing permissions [${missingPermissions.join(", ")}]`, "FORBIDDEN", 403);
|
|
97
99
|
return;
|
package/dist/middleware/index.js
CHANGED
|
@@ -19,3 +19,5 @@ __exportStar(require("./validate.middleware"), exports);
|
|
|
19
19
|
__exportStar(require("./request-id.middleware"), exports);
|
|
20
20
|
__exportStar(require("./authorize.middleware"), exports);
|
|
21
21
|
__exportStar(require("./validated-merge.middleware"), exports);
|
|
22
|
+
__exportStar(require("./require-internal.middleware"), exports);
|
|
23
|
+
__exportStar(require("./require-auth.middleware"), exports);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { AccountRole } from "../enums";
|
|
3
|
+
import { GlobalPermission } from "../enums";
|
|
4
|
+
import { ILogger } from "../utils";
|
|
5
|
+
import { InternalJwtVerifier } from "../jwt/internal-jwt-verifier";
|
|
6
|
+
/**
|
|
7
|
+
* REQUIRE AUTH MIDDLEWARE (@discover-cloud/shared)
|
|
8
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
* Verifies the internal JWT, resolves permissions from the Redis cache,
|
|
10
|
+
* and attaches the typed AccessContext to req.accessContext.
|
|
11
|
+
*
|
|
12
|
+
* Moved to shared so every downstream service (auth, user, cloud, insights)
|
|
13
|
+
* uses the same verified implementation rather than maintaining their own copy.
|
|
14
|
+
*
|
|
15
|
+
* Token extraction:
|
|
16
|
+
* Reads a Bearer token from the Authorization header.
|
|
17
|
+
* Does not support cookie-based auth — that is the API gateway's responsibility.
|
|
18
|
+
*
|
|
19
|
+
* On success:
|
|
20
|
+
* req.internalAuth → raw verified JWT payload (use for jti, caller, requestId)
|
|
21
|
+
* req.accessContext → clean typed context (use in controllers and services)
|
|
22
|
+
*
|
|
23
|
+
* On failure:
|
|
24
|
+
* Returns a structured error response and does NOT call next().
|
|
25
|
+
* next(err) is only called for genuinely unexpected errors (not auth failures).
|
|
26
|
+
*
|
|
27
|
+
* Inline guards (requirePermission, requireRole):
|
|
28
|
+
* Convenience methods for single-route guards. For router-level permission
|
|
29
|
+
* enforcement, prefer the standalone factories in permission.middleware.ts —
|
|
30
|
+
* they don't require a reference to this class instance.
|
|
31
|
+
*
|
|
32
|
+
* Logging:
|
|
33
|
+
* Unexpected errors are logged at "error" level with requestId for correlation.
|
|
34
|
+
* Auth failures (expired, invalid token) are intentionally not logged at error
|
|
35
|
+
* level — they are client errors, not server faults.
|
|
36
|
+
*
|
|
37
|
+
* Usage in each service's app.ts:
|
|
38
|
+
* import { RequireAuthMiddleware } from "@discover-cloud/shared";
|
|
39
|
+
*
|
|
40
|
+
* const requireAuth = new RequireAuthMiddleware(jwtVerifier, logger);
|
|
41
|
+
* router.use(requireAuth.handle);
|
|
42
|
+
*/
|
|
43
|
+
export declare class RequireAuthMiddleware {
|
|
44
|
+
private readonly verifier;
|
|
45
|
+
private readonly logger;
|
|
46
|
+
constructor(verifier: InternalJwtVerifier, logger?: ILogger);
|
|
47
|
+
/**
|
|
48
|
+
* Core authentication middleware.
|
|
49
|
+
* Apply via: router.use(requireAuth.handle) or router.get("/", requireAuth.handle, controller.x)
|
|
50
|
+
*/
|
|
51
|
+
handle: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Inline permission guard — use when this class instance is available at the call site.
|
|
54
|
+
* For router-level guards, prefer requireGlobalPermission() from permission.middleware.ts.
|
|
55
|
+
*
|
|
56
|
+
* Usage: router.get("/", requireAuth.handle, requireAuth.requirePermission(P.READ_ACCOUNTS), controller.list)
|
|
57
|
+
*/
|
|
58
|
+
requirePermission: (permission: GlobalPermission) => (req: Request, res: Response, next: NextFunction) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Inline role guard — use when this class instance is available at the call site.
|
|
61
|
+
*
|
|
62
|
+
* Prefer permission-based guards over role-based guards where possible.
|
|
63
|
+
* Role checks are appropriate for coarse access gates (e.g. "only admins enter this router").
|
|
64
|
+
*/
|
|
65
|
+
requireRole: (role: AccountRole) => (req: Request, res: Response, next: NextFunction) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Extracts the Bearer token from the Authorization header.
|
|
68
|
+
* Returns null (not an error) if the header is absent or malformed —
|
|
69
|
+
* callers decide how to handle the missing token.
|
|
70
|
+
*/
|
|
71
|
+
private extractBearer;
|
|
72
|
+
/**
|
|
73
|
+
* Maps jose verification errors to appropriate HTTP responses.
|
|
74
|
+
*
|
|
75
|
+
* Error categories:
|
|
76
|
+
* JWTExpired → 401 TOKEN_EXPIRED (client should refresh)
|
|
77
|
+
* JWTClaimValidationFailed,
|
|
78
|
+
* JWSSignatureVerificationFailed,
|
|
79
|
+
* JWSInvalid, JWTInvalid → 401 INVALID_TOKEN (client should re-login)
|
|
80
|
+
* Anything else → 503 SERVICE_UNAVAILABLE (unexpected — log at error)
|
|
81
|
+
*
|
|
82
|
+
* Security note: generic "invalid token" messages are intentional.
|
|
83
|
+
* Never expose which specific claim failed — that aids token forgery attempts.
|
|
84
|
+
*/
|
|
85
|
+
private handleJwtError;
|
|
86
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RequireAuthMiddleware = void 0;
|
|
4
|
+
const jose_1 = require("jose");
|
|
5
|
+
const utils_1 = require("../utils");
|
|
6
|
+
const internal_jwt_verifier_1 = require("../jwt/internal-jwt-verifier");
|
|
7
|
+
const utils_2 = require("../utils");
|
|
8
|
+
/**
|
|
9
|
+
* REQUIRE AUTH MIDDLEWARE (@discover-cloud/shared)
|
|
10
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* Verifies the internal JWT, resolves permissions from the Redis cache,
|
|
12
|
+
* and attaches the typed AccessContext to req.accessContext.
|
|
13
|
+
*
|
|
14
|
+
* Moved to shared so every downstream service (auth, user, cloud, insights)
|
|
15
|
+
* uses the same verified implementation rather than maintaining their own copy.
|
|
16
|
+
*
|
|
17
|
+
* Token extraction:
|
|
18
|
+
* Reads a Bearer token from the Authorization header.
|
|
19
|
+
* Does not support cookie-based auth — that is the API gateway's responsibility.
|
|
20
|
+
*
|
|
21
|
+
* On success:
|
|
22
|
+
* req.internalAuth → raw verified JWT payload (use for jti, caller, requestId)
|
|
23
|
+
* req.accessContext → clean typed context (use in controllers and services)
|
|
24
|
+
*
|
|
25
|
+
* On failure:
|
|
26
|
+
* Returns a structured error response and does NOT call next().
|
|
27
|
+
* next(err) is only called for genuinely unexpected errors (not auth failures).
|
|
28
|
+
*
|
|
29
|
+
* Inline guards (requirePermission, requireRole):
|
|
30
|
+
* Convenience methods for single-route guards. For router-level permission
|
|
31
|
+
* enforcement, prefer the standalone factories in permission.middleware.ts —
|
|
32
|
+
* they don't require a reference to this class instance.
|
|
33
|
+
*
|
|
34
|
+
* Logging:
|
|
35
|
+
* Unexpected errors are logged at "error" level with requestId for correlation.
|
|
36
|
+
* Auth failures (expired, invalid token) are intentionally not logged at error
|
|
37
|
+
* level — they are client errors, not server faults.
|
|
38
|
+
*
|
|
39
|
+
* Usage in each service's app.ts:
|
|
40
|
+
* import { RequireAuthMiddleware } from "@discover-cloud/shared";
|
|
41
|
+
*
|
|
42
|
+
* const requireAuth = new RequireAuthMiddleware(jwtVerifier, logger);
|
|
43
|
+
* router.use(requireAuth.handle);
|
|
44
|
+
*/
|
|
45
|
+
class RequireAuthMiddleware {
|
|
46
|
+
constructor(verifier, logger = utils_1.noopLogger) {
|
|
47
|
+
this.verifier = verifier;
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
/**
|
|
50
|
+
* Core authentication middleware.
|
|
51
|
+
* Apply via: router.use(requireAuth.handle) or router.get("/", requireAuth.handle, controller.x)
|
|
52
|
+
*/
|
|
53
|
+
this.handle = async (req, res, next) => {
|
|
54
|
+
const token = this.extractBearer(req);
|
|
55
|
+
if (!token) {
|
|
56
|
+
(0, utils_2.failure)(res, req, "Missing Authorization header", "UNAUTHORIZED", 401);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const { payload, context } = await this.verifier.buildAccessContext(token);
|
|
61
|
+
req.internalAuth = payload;
|
|
62
|
+
req.accessContext = context;
|
|
63
|
+
this.logger.debug({
|
|
64
|
+
requestId: req.id,
|
|
65
|
+
// log accountId for humans, serviceId for machines — never log both
|
|
66
|
+
identity: context.kind === "human" ? context.accountId : context.serviceId,
|
|
67
|
+
kind: context.kind,
|
|
68
|
+
caller: payload.caller,
|
|
69
|
+
}, "Request authenticated");
|
|
70
|
+
next();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
this.handleJwtError(err, req, res);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Inline permission guard — use when this class instance is available at the call site.
|
|
78
|
+
* For router-level guards, prefer requireGlobalPermission() from permission.middleware.ts.
|
|
79
|
+
*
|
|
80
|
+
* Usage: router.get("/", requireAuth.handle, requireAuth.requirePermission(P.READ_ACCOUNTS), controller.list)
|
|
81
|
+
*/
|
|
82
|
+
this.requirePermission = (permission) => (req, res, next) => {
|
|
83
|
+
const ctx = req.accessContext;
|
|
84
|
+
if (!ctx) {
|
|
85
|
+
// Should not happen after handle() — indicates middleware ordering bug
|
|
86
|
+
(0, utils_2.failure)(res, req, "No access context — ensure requireAuth runs first", "UNAUTHORIZED", 401);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(ctx, permission)) {
|
|
90
|
+
(0, utils_2.failure)(res, req, `Missing required permission: ${permission}`, "FORBIDDEN", 403);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
next();
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Inline role guard — use when this class instance is available at the call site.
|
|
97
|
+
*
|
|
98
|
+
* Prefer permission-based guards over role-based guards where possible.
|
|
99
|
+
* Role checks are appropriate for coarse access gates (e.g. "only admins enter this router").
|
|
100
|
+
*/
|
|
101
|
+
this.requireRole = (role) => (req, res, next) => {
|
|
102
|
+
const ctx = req.accessContext;
|
|
103
|
+
if (!ctx) {
|
|
104
|
+
(0, utils_2.failure)(res, req, "No access context — ensure requireAuth runs first", "UNAUTHORIZED", 401);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!internal_jwt_verifier_1.InternalJwtVerifier.hasRole(ctx, role)) {
|
|
108
|
+
(0, utils_2.failure)(res, req, `Missing required role: ${role}`, "FORBIDDEN", 403);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
next();
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/* ────────────────────────────────────────────────────────────────────
|
|
115
|
+
PRIVATE HELPERS
|
|
116
|
+
──────────────────────────────────────────────────────────────────── */
|
|
117
|
+
/**
|
|
118
|
+
* Extracts the Bearer token from the Authorization header.
|
|
119
|
+
* Returns null (not an error) if the header is absent or malformed —
|
|
120
|
+
* callers decide how to handle the missing token.
|
|
121
|
+
*/
|
|
122
|
+
extractBearer(req) {
|
|
123
|
+
const header = req.headers.authorization;
|
|
124
|
+
if (!header) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const [scheme, token] = header.trim().split(/\s+/);
|
|
128
|
+
if (scheme !== "Bearer" || !token) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return token;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Maps jose verification errors to appropriate HTTP responses.
|
|
135
|
+
*
|
|
136
|
+
* Error categories:
|
|
137
|
+
* JWTExpired → 401 TOKEN_EXPIRED (client should refresh)
|
|
138
|
+
* JWTClaimValidationFailed,
|
|
139
|
+
* JWSSignatureVerificationFailed,
|
|
140
|
+
* JWSInvalid, JWTInvalid → 401 INVALID_TOKEN (client should re-login)
|
|
141
|
+
* Anything else → 503 SERVICE_UNAVAILABLE (unexpected — log at error)
|
|
142
|
+
*
|
|
143
|
+
* Security note: generic "invalid token" messages are intentional.
|
|
144
|
+
* Never expose which specific claim failed — that aids token forgery attempts.
|
|
145
|
+
*/
|
|
146
|
+
handleJwtError(err, req, res) {
|
|
147
|
+
if (err instanceof jose_1.errors.JWTExpired) {
|
|
148
|
+
(0, utils_2.failure)(res, req, "Your session has expired. Please log in again.", "TOKEN_EXPIRED", 401);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (err instanceof jose_1.errors.JWTClaimValidationFailed ||
|
|
152
|
+
err instanceof jose_1.errors.JWSSignatureVerificationFailed ||
|
|
153
|
+
err instanceof jose_1.errors.JWSInvalid ||
|
|
154
|
+
err instanceof jose_1.errors.JWTInvalid) {
|
|
155
|
+
(0, utils_2.failure)(res, req, "Token is invalid or has been tampered with.", "INVALID_TOKEN", 401);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Unexpected error — log for investigation, return a safe 503
|
|
159
|
+
this.logger.error({ err, requestId: req.id }, "RequireAuthMiddleware: unexpected error during JWT verification");
|
|
160
|
+
(0, utils_2.failure)(res, req, "Authentication service is temporarily unavailable.", "SERVICE_UNAVAILABLE", 503);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
exports.RequireAuthMiddleware = RequireAuthMiddleware;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import { ILogger } from "../utils/logger.utils";
|
|
3
|
+
export interface RequireInternalOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Gateway JWKS URI.
|
|
6
|
+
* Defaults to GATEWAY_JWKS_URI env var, then the docker-compose default.
|
|
7
|
+
* Must point to the gateway, not any individual service.
|
|
8
|
+
*/
|
|
9
|
+
gatewayJwksUri?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Permitted serviceIds. Reject any machine token whose serviceId is
|
|
12
|
+
* not in this list, even if the signature is valid.
|
|
13
|
+
* Omit to accept any valid machine token (not recommended in production).
|
|
14
|
+
*/
|
|
15
|
+
allowedServices?: string[];
|
|
16
|
+
logger?: ILogger;
|
|
17
|
+
}
|
|
18
|
+
export declare function requireInternal(options?: RequireInternalOptions): (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.requireInternal = requireInternal;
|
|
37
|
+
const jose = __importStar(require("jose"));
|
|
38
|
+
const logger_utils_1 = require("../utils/logger.utils");
|
|
39
|
+
/**
|
|
40
|
+
* REQUIRE INTERNAL MIDDLEWARE (@discover-cloud/shared)
|
|
41
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
42
|
+
* Protects /internal/* routes by verifying a gateway-signed machine JWT.
|
|
43
|
+
* Replaces X-Internal-Secret on all service-to-service endpoints.
|
|
44
|
+
*
|
|
45
|
+
* Trust model:
|
|
46
|
+
* All machine tokens are signed by the gateway's private key and verified
|
|
47
|
+
* against the gateway's JWKS endpoint — the same trust root used for
|
|
48
|
+
* human tokens. There is exactly one signing authority in the system.
|
|
49
|
+
*
|
|
50
|
+
* Verification:
|
|
51
|
+
* 1. Extract Bearer token from Authorization header
|
|
52
|
+
* 2. Verify RS256 signature against gateway JWKS
|
|
53
|
+
* 3. Assert typ === "internal" (token confusion defence)
|
|
54
|
+
* 4. Assert isMachine === true (rejects human tokens on internal routes)
|
|
55
|
+
* 5. Assert serviceId is in the allowlist (optional but recommended)
|
|
56
|
+
* 6. Attach MachineAccessContext to req.accessContext
|
|
57
|
+
*
|
|
58
|
+
* JWKS caching:
|
|
59
|
+
* One RemoteJWKSet instance is created per middleware instance (i.e. per
|
|
60
|
+
* service process), cached internally by jose for JWKS_CACHE_MAX_AGE_MS.
|
|
61
|
+
* No per-request network call under normal operation.
|
|
62
|
+
*
|
|
63
|
+
* Allowlist:
|
|
64
|
+
* Pass allowedServices to restrict which services can call this endpoint.
|
|
65
|
+
* Example: requireInternal({ allowedServices: ["cloud-service"] })
|
|
66
|
+
* Defaults to accepting any valid machine token if omitted.
|
|
67
|
+
* Always set this in production — it limits blast radius if a service
|
|
68
|
+
* is compromised.
|
|
69
|
+
*
|
|
70
|
+
* Usage:
|
|
71
|
+
* // insights-service internal routes
|
|
72
|
+
* router.use("/internal", requireInternal({
|
|
73
|
+
* gatewayJwksUri: process.env.GATEWAY_JWKS_URI,
|
|
74
|
+
* allowedServices: ["cloud-service"],
|
|
75
|
+
* logger,
|
|
76
|
+
* }));
|
|
77
|
+
*/
|
|
78
|
+
const CLOCK_TOLERANCE_S = 30;
|
|
79
|
+
const JWKS_CACHE_MAX_AGE_MS = 10 * 60 * 1000;
|
|
80
|
+
const JWKS_FETCH_TIMEOUT_MS = 5000;
|
|
81
|
+
function requireInternal(options = {}) {
|
|
82
|
+
const logger = options.logger ?? logger_utils_1.noopLogger;
|
|
83
|
+
const jwksUri = options.gatewayJwksUri ??
|
|
84
|
+
process.env["GATEWAY_JWKS_URI"] ??
|
|
85
|
+
"http://api-gateway:3000/.well-known/jwks.json";
|
|
86
|
+
const issuer = process.env["INTERNAL_JWT_ISSUER"] ?? "discover-cloud:api-gateway";
|
|
87
|
+
const audience = process.env["INTERNAL_JWT_AUDIENCE"] ?? "discover-cloud:internal";
|
|
88
|
+
// One JWKS instance per middleware — jose caches the key set internally
|
|
89
|
+
const jwks = jose.createRemoteJWKSet(new URL(jwksUri), {
|
|
90
|
+
cacheMaxAge: JWKS_CACHE_MAX_AGE_MS,
|
|
91
|
+
timeoutDuration: JWKS_FETCH_TIMEOUT_MS,
|
|
92
|
+
});
|
|
93
|
+
const { allowedServices } = options;
|
|
94
|
+
return async (req, res, next) => {
|
|
95
|
+
const header = req.headers.authorization;
|
|
96
|
+
if (!header?.startsWith("Bearer ")) {
|
|
97
|
+
res.status(401).json({
|
|
98
|
+
success: false,
|
|
99
|
+
error: { code: "UNAUTHORIZED", message: "Missing Authorization header" },
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const token = header.slice(7);
|
|
104
|
+
try {
|
|
105
|
+
const { payload } = await jose.jwtVerify(token, jwks, {
|
|
106
|
+
issuer,
|
|
107
|
+
audience,
|
|
108
|
+
algorithms: ["RS256"],
|
|
109
|
+
clockTolerance: CLOCK_TOLERANCE_S,
|
|
110
|
+
});
|
|
111
|
+
// Token confusion defence — reject human tokens that somehow
|
|
112
|
+
// reach an internal route (wrong header forwarded by caller)
|
|
113
|
+
if (payload["typ"] !== "internal") {
|
|
114
|
+
res.status(401).json({
|
|
115
|
+
success: false,
|
|
116
|
+
error: { code: "INVALID_TOKEN", message: "Token type must be 'internal'" },
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Machine guard — reject human internal tokens on machine-only routes
|
|
121
|
+
if (payload["isMachine"] !== true) {
|
|
122
|
+
res.status(403).json({
|
|
123
|
+
success: false,
|
|
124
|
+
error: { code: "FORBIDDEN", message: "Machine token required for internal routes" },
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const serviceId = payload["serviceId"];
|
|
129
|
+
if (typeof serviceId !== "string") {
|
|
130
|
+
res.status(401).json({
|
|
131
|
+
success: false,
|
|
132
|
+
error: { code: "INVALID_TOKEN", message: "Token missing serviceId" },
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Allowlist — tightest possible scope per endpoint
|
|
137
|
+
if (allowedServices && !allowedServices.includes(serviceId)) {
|
|
138
|
+
logger.warn({ requestId: req.id, serviceId }, "[requireInternal] serviceId not in allowlist");
|
|
139
|
+
res.status(403).json({
|
|
140
|
+
success: false,
|
|
141
|
+
error: { code: "FORBIDDEN", message: "Service not permitted to call this endpoint" },
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const context = { kind: "machine", serviceId };
|
|
146
|
+
req.accessContext = context;
|
|
147
|
+
logger.debug({ requestId: req.id, serviceId, jti: payload.jti }, "[requireInternal] Machine token verified");
|
|
148
|
+
next();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof jose.errors.JWTExpired) {
|
|
152
|
+
res.status(401).json({
|
|
153
|
+
success: false,
|
|
154
|
+
error: { code: "TOKEN_EXPIRED", message: "Machine token has expired" },
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (err instanceof jose.errors.JWSSignatureVerificationFailed ||
|
|
159
|
+
err instanceof jose.errors.JWTClaimValidationFailed ||
|
|
160
|
+
err instanceof jose.errors.JWSInvalid ||
|
|
161
|
+
err instanceof jose.errors.JWTInvalid) {
|
|
162
|
+
res.status(401).json({
|
|
163
|
+
success: false,
|
|
164
|
+
error: { code: "INVALID_TOKEN", message: "Machine token is invalid" },
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
logger.error({ err, requestId: req.id }, "[requireInternal] Unexpected error verifying machine token");
|
|
169
|
+
res.status(503).json({
|
|
170
|
+
success: false,
|
|
171
|
+
error: { code: "SERVICE_UNAVAILABLE", message: "Authentication temporarily unavailable" },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -60,7 +60,8 @@ export interface HumanInternalJwtPayload extends BaseInternalJwtPayload {
|
|
|
60
60
|
isMachine: false;
|
|
61
61
|
accountId: string;
|
|
62
62
|
accountRole: AccountRole;
|
|
63
|
-
externalJti
|
|
63
|
+
externalJti: string;
|
|
64
|
+
externalExp: number;
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
67
|
* MachineInternalJwtPayload
|
|
@@ -73,6 +74,10 @@ export interface MachineInternalJwtPayload extends BaseInternalJwtPayload {
|
|
|
73
74
|
}
|
|
74
75
|
/** Full internal JWT payload union — use in verifiers and auth middleware */
|
|
75
76
|
export type InternalJwtPayload = HumanInternalJwtPayload | MachineInternalJwtPayload;
|
|
77
|
+
export interface TokenRevocationContext {
|
|
78
|
+
jti: string;
|
|
79
|
+
exp: number;
|
|
80
|
+
}
|
|
76
81
|
export interface HumanAccessContext {
|
|
77
82
|
kind: "human";
|
|
78
83
|
accountId: string;
|