@discover-cloud/shared 1.0.11 → 1.2.0
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/authorization/permission-cache.service.d.ts +14 -1
- package/dist/authorization/permission-cache.service.js +84 -16
- package/dist/authorization/permissions.d.ts +19 -15
- package/dist/authorization/permissions.js +24 -21
- package/dist/dtos/auth-service.dto.d.ts +13 -0
- package/dist/dtos/cloud-service.dto.d.ts +25 -10
- package/dist/dtos/insights-service.dto.d.ts +24 -8
- package/dist/dtos/response.dto.d.ts +80 -10
- package/dist/dtos/response.dto.js +23 -4
- package/dist/dtos/user-service.dto.d.ts +7 -0
- package/dist/enums/domain.enums.d.ts +45 -14
- package/dist/enums/domain.enums.js +40 -6
- package/dist/enums/permissions.enums.d.ts +12 -52
- package/dist/enums/permissions.enums.js +55 -59
- package/dist/errors/app-error.d.ts +17 -14
- package/dist/errors/app-error.js +19 -15
- package/dist/errors/http-errors.d.ts +12 -5
- package/dist/errors/http-errors.js +27 -5
- package/dist/http/service-client.d.ts +37 -12
- package/dist/http/service-client.js +43 -17
- package/dist/jwt/internal-jwt-verifier.d.ts +9 -3
- package/dist/jwt/internal-jwt-verifier.js +49 -26
- package/dist/middleware/authorize.middleware.d.ts +38 -6
- package/dist/middleware/authorize.middleware.js +62 -37
- package/dist/middleware/error-handler.middleware.d.ts +21 -7
- package/dist/middleware/error-handler.middleware.js +42 -14
- package/dist/middleware/request-id.middleware.d.ts +12 -10
- package/dist/middleware/request-id.middleware.js +15 -15
- package/dist/middleware/validate.middleware.d.ts +22 -13
- package/dist/middleware/validate.middleware.js +25 -16
- package/dist/middleware/validated-merge.middleware.js +1 -2
- package/dist/types/express.types.d.ts +37 -32
- package/dist/types/express.types.js +13 -11
- package/dist/utils/date.utils.d.ts +18 -4
- package/dist/utils/date.utils.js +18 -4
- package/dist/utils/env.d.ts +46 -0
- package/dist/utils/env.js +61 -0
- package/dist/utils/logger.utils.d.ts +31 -16
- package/dist/utils/logger.utils.js +55 -20
- package/dist/utils/response.utils.d.ts +47 -5
- package/dist/utils/response.utils.js +50 -7
- package/package.json +1 -1
|
@@ -3,65 +3,87 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ServiceUnavailableError = exports.InternalServerError = exports.TooManyRequestsError = exports.UnprocessableEntityError = exports.ConflictError = exports.NotFoundError = exports.ForbiddenError = exports.UnauthorizedError = exports.BadRequestError = void 0;
|
|
4
4
|
const app_error_1 = require("./app-error");
|
|
5
5
|
/**
|
|
6
|
-
* HTTP ERRORS
|
|
7
|
-
*
|
|
6
|
+
* HTTP ERRORS (@discover-cloud/shared)
|
|
7
|
+
* ───────────────────────────────────────
|
|
8
8
|
* Typed subclasses of AppError for every common HTTP error status.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* GlobalErrorHandler catches these via `instanceof AppError` and maps
|
|
11
|
+
* statusCode + code directly to the response — no additional switch/if needed.
|
|
11
12
|
*
|
|
12
13
|
* Usage:
|
|
13
14
|
* throw new NotFoundError("Account not found");
|
|
14
15
|
* throw new ConflictError("Email already in use");
|
|
15
|
-
* throw new BadRequestError("
|
|
16
|
+
* throw new BadRequestError("Validation failed", { field: "email" });
|
|
17
|
+
*
|
|
18
|
+
* Adding a new error type:
|
|
19
|
+
* 1. Add a subclass here following the existing pattern.
|
|
20
|
+
* 2. Pick the correct HTTP status code.
|
|
21
|
+
* 3. Choose a SCREAMING_SNAKE_CASE code that is stable across versions
|
|
22
|
+
* (clients may key on it for error handling).
|
|
16
23
|
*/
|
|
24
|
+
// 400 — The request is malformed or contains invalid input.
|
|
17
25
|
class BadRequestError extends app_error_1.AppError {
|
|
18
26
|
constructor(message = "Bad Request", details) {
|
|
19
27
|
super("BAD_REQUEST", 400, message, details);
|
|
20
28
|
}
|
|
21
29
|
}
|
|
22
30
|
exports.BadRequestError = BadRequestError;
|
|
31
|
+
// 401 — No valid credentials were supplied (missing or expired token).
|
|
32
|
+
// Distinct from 403: the client may retry after authenticating.
|
|
23
33
|
class UnauthorizedError extends app_error_1.AppError {
|
|
24
34
|
constructor(message = "Unauthorized", details) {
|
|
25
35
|
super("UNAUTHORIZED", 401, message, details);
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
exports.UnauthorizedError = UnauthorizedError;
|
|
39
|
+
// 403 — Valid credentials were supplied but the caller lacks permission.
|
|
40
|
+
// Retrying with the same credentials will not help.
|
|
29
41
|
class ForbiddenError extends app_error_1.AppError {
|
|
30
42
|
constructor(message = "Forbidden", details) {
|
|
31
43
|
super("FORBIDDEN", 403, message, details);
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
46
|
exports.ForbiddenError = ForbiddenError;
|
|
47
|
+
// 404 — The requested resource does not exist.
|
|
35
48
|
class NotFoundError extends app_error_1.AppError {
|
|
36
49
|
constructor(message = "Not Found", details) {
|
|
37
50
|
super("NOT_FOUND", 404, message, details);
|
|
38
51
|
}
|
|
39
52
|
}
|
|
40
53
|
exports.NotFoundError = NotFoundError;
|
|
54
|
+
// 409 — The request conflicts with the current state of the resource
|
|
55
|
+
// (e.g. duplicate email, concurrent edit conflict).
|
|
41
56
|
class ConflictError extends app_error_1.AppError {
|
|
42
57
|
constructor(message = "Conflict", details) {
|
|
43
58
|
super("CONFLICT", 409, message, details);
|
|
44
59
|
}
|
|
45
60
|
}
|
|
46
61
|
exports.ConflictError = ConflictError;
|
|
62
|
+
// 422 — The request is well-formed but semantically invalid
|
|
63
|
+
// (e.g. Zod validation passed structurally but business rules failed).
|
|
47
64
|
class UnprocessableEntityError extends app_error_1.AppError {
|
|
48
65
|
constructor(message = "Unprocessable Entity", details) {
|
|
49
66
|
super("UNPROCESSABLE_ENTITY", 422, message, details);
|
|
50
67
|
}
|
|
51
68
|
}
|
|
52
69
|
exports.UnprocessableEntityError = UnprocessableEntityError;
|
|
70
|
+
// 429 — The caller has exceeded the rate limit. Clients should back off.
|
|
53
71
|
class TooManyRequestsError extends app_error_1.AppError {
|
|
54
72
|
constructor(message = "Too Many Requests", details) {
|
|
55
73
|
super("TOO_MANY_REQUESTS", 429, message, details);
|
|
56
74
|
}
|
|
57
75
|
}
|
|
58
76
|
exports.TooManyRequestsError = TooManyRequestsError;
|
|
77
|
+
// 500 — An unexpected internal error occurred. Should never be thrown
|
|
78
|
+
// intentionally — use more specific errors wherever possible.
|
|
59
79
|
class InternalServerError extends app_error_1.AppError {
|
|
60
80
|
constructor(message = "Internal Server Error", details) {
|
|
61
81
|
super("INTERNAL_SERVER_ERROR", 500, message, details);
|
|
62
82
|
}
|
|
63
83
|
}
|
|
64
84
|
exports.InternalServerError = InternalServerError;
|
|
85
|
+
// 503 — The service (or a dependency) is temporarily unavailable.
|
|
86
|
+
// Clients may retry after the indicated delay.
|
|
65
87
|
class ServiceUnavailableError extends app_error_1.AppError {
|
|
66
88
|
constructor(message = "Service Unavailable", details) {
|
|
67
89
|
super("SERVICE_UNAVAILABLE", 503, message, details);
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
|
2
2
|
import { Request } from "express";
|
|
3
3
|
/**
|
|
4
|
-
* SERVICE CLIENT
|
|
5
|
-
*
|
|
4
|
+
* SERVICE CLIENT (@discover-cloud/shared)
|
|
5
|
+
* ─────────────────────────────────────────
|
|
6
6
|
* HTTP client for service-to-service communication.
|
|
7
7
|
*
|
|
8
8
|
* Two call patterns:
|
|
9
9
|
*
|
|
10
|
-
* 1
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Pattern 1 — JWT-forwarding (user-initiated requests)
|
|
11
|
+
* Pass the current Express Request — Authorization + x-request-id are
|
|
12
|
+
* forwarded automatically. Used when the downstream service needs to
|
|
13
|
+
* act on behalf of the authenticated user.
|
|
14
|
+
* Methods: getWithAuth, postWithAuth, patchWithAuth, deleteWithAuth
|
|
14
15
|
*
|
|
15
|
-
* 2
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Pattern 2 — Internal calls (service-initiated, no incoming request)
|
|
17
|
+
* Pass explicit headers — used when calls originate from service logic
|
|
18
|
+
* rather than from an HTTP handler (e.g. auth service → user service
|
|
19
|
+
* during account creation). Caller is responsible for providing the
|
|
20
|
+
* X-Internal-Secret header.
|
|
21
|
+
* Methods: get, post, patch, delete
|
|
19
22
|
*
|
|
20
|
-
* Retry strategy:
|
|
21
|
-
*
|
|
23
|
+
* Retry strategy:
|
|
24
|
+
* - Retries on network errors and 5xx responses only (up to 3 attempts,
|
|
25
|
+
* exponential backoff). 4xx responses are never retried — they are
|
|
26
|
+
* deterministic caller errors that won't resolve on retry.
|
|
27
|
+
*
|
|
28
|
+
* Timeout:
|
|
29
|
+
* - 8 seconds per request (covers retry delays separately via axiosRetry).
|
|
30
|
+
* Adjust per-call via config.timeout if a specific route needs more.
|
|
22
31
|
*/
|
|
23
32
|
export declare class ServiceClient {
|
|
24
33
|
readonly http: AxiosInstance;
|
|
@@ -31,6 +40,22 @@ export declare class ServiceClient {
|
|
|
31
40
|
post<T = unknown>(url: string, data: unknown, headers: Record<string, string>, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
32
41
|
patch<T = unknown>(url: string, data: unknown, headers: Record<string, string>, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
33
42
|
delete<T = unknown>(url: string, headers: Record<string, string>, config?: AxiosRequestConfig): Promise<AxiosResponse<T>>;
|
|
43
|
+
/**
|
|
44
|
+
* mergeAuth
|
|
45
|
+
* Extracts Authorization and x-request-id from the Express request
|
|
46
|
+
* and merges them into the Axios config headers.
|
|
47
|
+
*
|
|
48
|
+
* x-request-id precedence:
|
|
49
|
+
* 1. req.id — set by express-request-id or equivalent middleware
|
|
50
|
+
* 2. x-request-id header forwarded from the upstream caller
|
|
51
|
+
* 3. Synthetic fallback (timestamp) — should not appear in production
|
|
52
|
+
* if request-id middleware is wired correctly in the gateway
|
|
53
|
+
*/
|
|
34
54
|
private mergeAuth;
|
|
55
|
+
/**
|
|
56
|
+
* mergeHeaders
|
|
57
|
+
* Merges caller-supplied headers into the Axios config.
|
|
58
|
+
* Caller-supplied headers take precedence over config.headers.
|
|
59
|
+
*/
|
|
35
60
|
private mergeHeaders;
|
|
36
61
|
}
|
|
@@ -7,24 +7,33 @@ exports.ServiceClient = void 0;
|
|
|
7
7
|
const axios_1 = __importDefault(require("axios"));
|
|
8
8
|
const axios_retry_1 = __importDefault(require("axios-retry"));
|
|
9
9
|
/**
|
|
10
|
-
* SERVICE CLIENT
|
|
11
|
-
*
|
|
10
|
+
* SERVICE CLIENT (@discover-cloud/shared)
|
|
11
|
+
* ─────────────────────────────────────────
|
|
12
12
|
* HTTP client for service-to-service communication.
|
|
13
13
|
*
|
|
14
14
|
* Two call patterns:
|
|
15
15
|
*
|
|
16
|
-
* 1
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Pattern 1 — JWT-forwarding (user-initiated requests)
|
|
17
|
+
* Pass the current Express Request — Authorization + x-request-id are
|
|
18
|
+
* forwarded automatically. Used when the downstream service needs to
|
|
19
|
+
* act on behalf of the authenticated user.
|
|
20
|
+
* Methods: getWithAuth, postWithAuth, patchWithAuth, deleteWithAuth
|
|
20
21
|
*
|
|
21
|
-
* 2
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Pattern 2 — Internal calls (service-initiated, no incoming request)
|
|
23
|
+
* Pass explicit headers — used when calls originate from service logic
|
|
24
|
+
* rather than from an HTTP handler (e.g. auth service → user service
|
|
25
|
+
* during account creation). Caller is responsible for providing the
|
|
26
|
+
* X-Internal-Secret header.
|
|
27
|
+
* Methods: get, post, patch, delete
|
|
25
28
|
*
|
|
26
|
-
* Retry strategy:
|
|
27
|
-
*
|
|
29
|
+
* Retry strategy:
|
|
30
|
+
* - Retries on network errors and 5xx responses only (up to 3 attempts,
|
|
31
|
+
* exponential backoff). 4xx responses are never retried — they are
|
|
32
|
+
* deterministic caller errors that won't resolve on retry.
|
|
33
|
+
*
|
|
34
|
+
* Timeout:
|
|
35
|
+
* - 8 seconds per request (covers retry delays separately via axiosRetry).
|
|
36
|
+
* Adjust per-call via config.timeout if a specific route needs more.
|
|
28
37
|
*/
|
|
29
38
|
class ServiceClient {
|
|
30
39
|
constructor(baseURL) {
|
|
@@ -36,8 +45,10 @@ class ServiceClient {
|
|
|
36
45
|
retries: 3,
|
|
37
46
|
retryDelay: axios_retry_1.default.exponentialDelay,
|
|
38
47
|
retryCondition: (err) => {
|
|
48
|
+
// Retry network-level errors (no response received)
|
|
39
49
|
if (axios_retry_1.default.isNetworkError(err))
|
|
40
50
|
return true;
|
|
51
|
+
// Retry transient server errors; never retry client errors
|
|
41
52
|
const status = err.response?.status ?? 0;
|
|
42
53
|
return status >= 500;
|
|
43
54
|
},
|
|
@@ -46,6 +57,7 @@ class ServiceClient {
|
|
|
46
57
|
/* ----------------------------------------------------------------
|
|
47
58
|
Pattern 1 — JWT-forwarding (pass Express req)
|
|
48
59
|
Forwards Authorization header + x-request-id automatically.
|
|
60
|
+
Use for endpoints that require a user identity downstream.
|
|
49
61
|
---------------------------------------------------------------- */
|
|
50
62
|
async getWithAuth(url, req, config) {
|
|
51
63
|
return this.http.get(url, this.mergeAuth(req, config));
|
|
@@ -61,8 +73,8 @@ class ServiceClient {
|
|
|
61
73
|
}
|
|
62
74
|
/* ----------------------------------------------------------------
|
|
63
75
|
Pattern 2 — Internal calls (explicit headers, no Express req)
|
|
64
|
-
Used for service-initiated calls
|
|
65
|
-
|
|
76
|
+
Used for service-initiated calls where there is no incoming HTTP
|
|
77
|
+
request to forward from. Caller must supply X-Internal-Secret.
|
|
66
78
|
---------------------------------------------------------------- */
|
|
67
79
|
async get(url, headers, config) {
|
|
68
80
|
return this.http.get(url, this.mergeHeaders(headers, config));
|
|
@@ -79,12 +91,21 @@ class ServiceClient {
|
|
|
79
91
|
/* ----------------------------------------------------------------
|
|
80
92
|
Private helpers
|
|
81
93
|
---------------------------------------------------------------- */
|
|
94
|
+
/**
|
|
95
|
+
* mergeAuth
|
|
96
|
+
* Extracts Authorization and x-request-id from the Express request
|
|
97
|
+
* and merges them into the Axios config headers.
|
|
98
|
+
*
|
|
99
|
+
* x-request-id precedence:
|
|
100
|
+
* 1. req.id — set by express-request-id or equivalent middleware
|
|
101
|
+
* 2. x-request-id header forwarded from the upstream caller
|
|
102
|
+
* 3. Synthetic fallback (timestamp) — should not appear in production
|
|
103
|
+
* if request-id middleware is wired correctly in the gateway
|
|
104
|
+
*/
|
|
82
105
|
mergeAuth(req, config) {
|
|
83
106
|
const authHeader = req.headers.authorization;
|
|
84
107
|
const upstream = req.headers["x-request-id"];
|
|
85
|
-
const requestId = req.id
|
|
86
|
-
?? (Array.isArray(upstream) ? upstream[0] : upstream)
|
|
87
|
-
?? `fallback-${Date.now()}`;
|
|
108
|
+
const requestId = req.id ?? (Array.isArray(upstream) ? upstream[0] : upstream) ?? `fallback-${Date.now()}`;
|
|
88
109
|
return {
|
|
89
110
|
...config,
|
|
90
111
|
headers: {
|
|
@@ -94,6 +115,11 @@ class ServiceClient {
|
|
|
94
115
|
},
|
|
95
116
|
};
|
|
96
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* mergeHeaders
|
|
120
|
+
* Merges caller-supplied headers into the Axios config.
|
|
121
|
+
* Caller-supplied headers take precedence over config.headers.
|
|
122
|
+
*/
|
|
97
123
|
mergeHeaders(headers, config) {
|
|
98
124
|
return {
|
|
99
125
|
...config,
|
|
@@ -13,23 +13,29 @@ export declare class InternalJwtVerifier {
|
|
|
13
13
|
context: AccessContext;
|
|
14
14
|
}>;
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* hasPermission
|
|
17
|
+
* Returns true if the context carries the given GlobalPermission.
|
|
17
18
|
* Machine contexts always return false — they carry no user permissions.
|
|
18
19
|
*/
|
|
19
20
|
static hasPermission(ctx: AccessContext, permission: GlobalPermission): boolean;
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
+
* requirePermission
|
|
23
|
+
* Throws JWTClaimValidationFailed if the permission is absent.
|
|
22
24
|
* Use in middleware for a one-liner gate:
|
|
23
25
|
* InternalJwtVerifier.requirePermission(ctx, GlobalPermission.MANAGE_ACCOUNTS);
|
|
24
26
|
*/
|
|
25
27
|
static requirePermission(ctx: AccessContext, permission: GlobalPermission): void;
|
|
26
28
|
/**
|
|
27
|
-
*
|
|
29
|
+
* hasRole
|
|
30
|
+
* Returns true if the context carries the given AccountRole.
|
|
28
31
|
* Machine contexts always return false.
|
|
29
32
|
*/
|
|
30
33
|
static hasRole(ctx: AccessContext, role: AccountRole): boolean;
|
|
31
34
|
/**
|
|
35
|
+
* requireRole
|
|
32
36
|
* Throws JWTClaimValidationFailed if the role doesn't match.
|
|
37
|
+
* Prefer requirePermission() for most gates — role checks are for the rare
|
|
38
|
+
* cases where a specific role (not just a permission) must be enforced.
|
|
33
39
|
*/
|
|
34
40
|
static requireRole(ctx: AccessContext, role: AccountRole): void;
|
|
35
41
|
}
|
|
@@ -38,17 +38,29 @@ const jose = __importStar(require("jose"));
|
|
|
38
38
|
/**
|
|
39
39
|
* INTERNAL JWT VERIFIER (@discover-cloud/shared)
|
|
40
40
|
* ──────────────────────────────────────────────────
|
|
41
|
-
* Verifies the internal JWT
|
|
42
|
-
*
|
|
41
|
+
* Verifies the internal JWT minted by the API Gateway, resolves permissions
|
|
42
|
+
* from Redis, and builds the AccessContext attached to req.accessContext.
|
|
43
43
|
*
|
|
44
|
-
* Key source:
|
|
45
|
-
*
|
|
44
|
+
* Key source:
|
|
45
|
+
* Remote JWKS — fetched from the Gateway at startup and cached for 10 minutes.
|
|
46
|
+
* Requests made during a JWKS cache miss are blocked until the fetch completes
|
|
47
|
+
* (jose handles this internally with a deduplication lock).
|
|
46
48
|
*
|
|
47
|
-
*
|
|
48
|
-
* 1.
|
|
49
|
-
* 2.
|
|
50
|
-
* 3.
|
|
51
|
-
* 4.
|
|
49
|
+
* Token flow per request:
|
|
50
|
+
* 1. Extract Bearer token from Authorization header (caller's responsibility)
|
|
51
|
+
* 2. Verify JWT signature, iss, aud, exp, nbf, typ → verifyInternal()
|
|
52
|
+
* 3. Guard typ === "internal" (token confusion protection)
|
|
53
|
+
* 4. For human tokens: resolve perms from Redis (derive from role on miss)
|
|
54
|
+
* 5. Build and return AccessContext → buildAccessContext()
|
|
55
|
+
*
|
|
56
|
+
* Token kinds:
|
|
57
|
+
* - Human (isMachine: false) — carries accountId, accountRole, perms
|
|
58
|
+
* - Machine (isMachine: true) — carries serviceId only; no perms
|
|
59
|
+
*
|
|
60
|
+
* Static helpers:
|
|
61
|
+
* InternalJwtVerifier.hasPermission / requirePermission / hasRole / requireRole
|
|
62
|
+
* are pure functions over AccessContext — safe to call anywhere after
|
|
63
|
+
* auth middleware has run and populated req.accessContext.
|
|
52
64
|
*/
|
|
53
65
|
const CLOCK_TOLERANCE_SECONDS = 30;
|
|
54
66
|
const JWKS_CACHE_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
|
|
@@ -56,10 +68,9 @@ const JWKS_FETCH_TIMEOUT_MS = 5000;
|
|
|
56
68
|
class InternalJwtVerifier {
|
|
57
69
|
constructor(permissionCache) {
|
|
58
70
|
this.permissionCache = permissionCache;
|
|
59
|
-
const gatewayJwksUri = process.env
|
|
60
|
-
|
|
61
|
-
this.
|
|
62
|
-
this.audience = process.env.INTERNAL_JWT_AUDIENCE ?? "discover-cloud:internal";
|
|
71
|
+
const gatewayJwksUri = process.env["GATEWAY_JWKS_URI"] ?? "http://api-gateway:3000/.well-known/jwks.json";
|
|
72
|
+
this.issuer = process.env["INTERNAL_JWT_ISSUER"] ?? "discover-cloud:api-gateway";
|
|
73
|
+
this.audience = process.env["INTERNAL_JWT_AUDIENCE"] ?? "discover-cloud:internal";
|
|
63
74
|
this.jwks = jose.createRemoteJWKSet(new URL(gatewayJwksUri), {
|
|
64
75
|
cacheMaxAge: JWKS_CACHE_MAX_AGE_MS,
|
|
65
76
|
timeoutDuration: JWKS_FETCH_TIMEOUT_MS,
|
|
@@ -70,20 +81,21 @@ class InternalJwtVerifier {
|
|
|
70
81
|
Full cryptographic verification + typ guard + clock skew tolerance.
|
|
71
82
|
Returns the raw verified payload.
|
|
72
83
|
|
|
73
|
-
Use when you need raw JWT claims (e.g. jti for
|
|
74
|
-
For
|
|
84
|
+
Use this directly when you need raw JWT claims (e.g. jti for
|
|
85
|
+
blacklisting). For all other cases, use buildAccessContext() which
|
|
86
|
+
layers permission resolution on top.
|
|
75
87
|
---------------------------------------------------------------- */
|
|
76
88
|
async verifyInternal(token) {
|
|
77
|
-
// jose.jwtVerify accepts string directly — the Uint8Array overload is
|
|
78
|
-
// for raw JWS. The type error comes from jose's union overloads; casting
|
|
79
|
-
// to the correct overload signature resolves it.
|
|
80
89
|
const { payload } = await jose.jwtVerify(token, this.jwks, {
|
|
81
90
|
issuer: this.issuer,
|
|
82
91
|
audience: this.audience,
|
|
83
92
|
algorithms: ["RS256"],
|
|
84
93
|
clockTolerance: CLOCK_TOLERANCE_SECONDS,
|
|
85
94
|
});
|
|
86
|
-
// typ guard — rejects external tokens
|
|
95
|
+
// Explicit typ guard — rejects external tokens (e.g. user-facing access
|
|
96
|
+
// tokens) that were accidentally forwarded past the gateway. This is a
|
|
97
|
+
// token confusion defence: even if an external token passes signature
|
|
98
|
+
// verification (same key pair), it will be rejected here.
|
|
87
99
|
if (payload.typ !== "internal") {
|
|
88
100
|
throw new jose.errors.JWTClaimValidationFailed(`Token type mismatch: expected "internal", got "${String(payload.typ)}". ` +
|
|
89
101
|
`Ensure clients are not forwarding external tokens past the gateway.`, payload, "typ", "check_failed");
|
|
@@ -103,7 +115,9 @@ class InternalJwtVerifier {
|
|
|
103
115
|
---------------------------------------------------------------- */
|
|
104
116
|
async buildAccessContext(token) {
|
|
105
117
|
const payload = await this.verifyInternal(token);
|
|
106
|
-
// Machine token — no user context, no permission cache lookup
|
|
118
|
+
// Machine token — no user context, no permission cache lookup.
|
|
119
|
+
// Machine services are authorised by their serviceId + X-Internal-Secret,
|
|
120
|
+
// not by role-based permissions.
|
|
107
121
|
if (payload.isMachine === true) {
|
|
108
122
|
const machinePayload = payload;
|
|
109
123
|
const context = {
|
|
@@ -112,29 +126,34 @@ class InternalJwtVerifier {
|
|
|
112
126
|
};
|
|
113
127
|
return { payload, context };
|
|
114
128
|
}
|
|
115
|
-
// Human token — resolve permissions from Redis
|
|
129
|
+
// Human token — resolve permissions from Redis.
|
|
130
|
+
// On a cache miss, permissions are derived from the static role map
|
|
131
|
+
// (no DB call — the role is already verified in the JWT payload).
|
|
116
132
|
const humanPayload = payload;
|
|
117
133
|
const perms = await this.permissionCache.resolve(humanPayload.accountId, humanPayload.accountRole);
|
|
118
134
|
const context = {
|
|
119
135
|
kind: "human",
|
|
120
136
|
accountId: humanPayload.accountId,
|
|
121
137
|
accountRole: humanPayload.accountRole,
|
|
122
|
-
perms, //
|
|
138
|
+
perms, // always from cache — never read from the JWT directly
|
|
123
139
|
};
|
|
124
140
|
return { payload, context };
|
|
125
141
|
}
|
|
126
142
|
/* ----------------------------------------------------------------
|
|
127
|
-
Static helpers —
|
|
143
|
+
Static helpers — call on req.accessContext after auth middleware runs.
|
|
144
|
+
These are pure functions; they do not touch Redis or the JWT verifier.
|
|
128
145
|
---------------------------------------------------------------- */
|
|
129
146
|
/**
|
|
130
|
-
*
|
|
147
|
+
* hasPermission
|
|
148
|
+
* Returns true if the context carries the given GlobalPermission.
|
|
131
149
|
* Machine contexts always return false — they carry no user permissions.
|
|
132
150
|
*/
|
|
133
151
|
static hasPermission(ctx, permission) {
|
|
134
152
|
return ctx.kind === "human" && ctx.perms.includes(permission);
|
|
135
153
|
}
|
|
136
154
|
/**
|
|
137
|
-
*
|
|
155
|
+
* requirePermission
|
|
156
|
+
* Throws JWTClaimValidationFailed if the permission is absent.
|
|
138
157
|
* Use in middleware for a one-liner gate:
|
|
139
158
|
* InternalJwtVerifier.requirePermission(ctx, GlobalPermission.MANAGE_ACCOUNTS);
|
|
140
159
|
*/
|
|
@@ -144,14 +163,18 @@ class InternalJwtVerifier {
|
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
/**
|
|
147
|
-
*
|
|
166
|
+
* hasRole
|
|
167
|
+
* Returns true if the context carries the given AccountRole.
|
|
148
168
|
* Machine contexts always return false.
|
|
149
169
|
*/
|
|
150
170
|
static hasRole(ctx, role) {
|
|
151
171
|
return ctx.kind === "human" && ctx.accountRole === role;
|
|
152
172
|
}
|
|
153
173
|
/**
|
|
174
|
+
* requireRole
|
|
154
175
|
* Throws JWTClaimValidationFailed if the role doesn't match.
|
|
176
|
+
* Prefer requirePermission() for most gates — role checks are for the rare
|
|
177
|
+
* cases where a specific role (not just a permission) must be enforced.
|
|
155
178
|
*/
|
|
156
179
|
static requireRole(ctx, role) {
|
|
157
180
|
if (!InternalJwtVerifier.hasRole(ctx, role)) {
|
|
@@ -1,22 +1,54 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from "express";
|
|
2
2
|
import { GlobalPermission } from "../enums";
|
|
3
3
|
/**
|
|
4
|
-
* AUTHORIZE MIDDLEWARE
|
|
5
|
-
*
|
|
6
|
-
* Permission-gates a route. Must run AFTER
|
|
4
|
+
* AUTHORIZE MIDDLEWARE (@discover-cloud/shared)
|
|
5
|
+
* ───────────────────────────────────────────────
|
|
6
|
+
* Permission-gates a route. Must run AFTER requireAuth middleware, which
|
|
7
|
+
* populates req.accessContext from the verified internal JWT + Redis cache.
|
|
8
|
+
*
|
|
7
9
|
* Uses InternalJwtVerifier.hasPermission — permissions come from the Redis
|
|
8
|
-
* permission cache loaded by RequireAuthMiddleware,
|
|
10
|
+
* permission cache loaded by RequireAuthMiddleware, never from the JWT itself.
|
|
11
|
+
*
|
|
12
|
+
* All error responses go through the shared failure() util to ensure a
|
|
13
|
+
* consistent ApiErrorResponse shape across every service. The error is
|
|
14
|
+
* returned directly (not passed to next()) because these are deterministic
|
|
15
|
+
* middleware guards — there is nothing to recover from downstream.
|
|
9
16
|
*
|
|
10
17
|
* Usage:
|
|
11
|
-
* router.delete(
|
|
18
|
+
* router.delete(
|
|
19
|
+
* "/accounts/:id",
|
|
12
20
|
* requireAuth,
|
|
13
21
|
* authorize(GlobalPermission.DELETE_ACCOUNT),
|
|
14
|
-
* controller.delete
|
|
22
|
+
* controller.delete,
|
|
15
23
|
* );
|
|
16
24
|
*/
|
|
17
25
|
export declare const authorize: (permission: GlobalPermission) => (req: Request, res: Response, next: NextFunction) => void;
|
|
18
26
|
/**
|
|
19
27
|
* authorizeAny
|
|
20
28
|
* Passes if the context has AT LEAST ONE of the provided permissions.
|
|
29
|
+
* Useful for routes accessible by multiple roles (e.g. SUPPORT_VIEW or VIEW_ANY_ACCOUNT).
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* router.get(
|
|
33
|
+
* "/accounts/:id",
|
|
34
|
+
* requireAuth,
|
|
35
|
+
* authorizeAny(GlobalPermission.VIEW_ANY_ACCOUNT, GlobalPermission.SUPPORT_VIEW),
|
|
36
|
+
* controller.getById,
|
|
37
|
+
* );
|
|
21
38
|
*/
|
|
22
39
|
export declare const authorizeAny: (...permissions: GlobalPermission[]) => (req: Request, res: Response, next: NextFunction) => void;
|
|
40
|
+
/**
|
|
41
|
+
* authorizeAll
|
|
42
|
+
* Passes only if the context has ALL of the provided permissions.
|
|
43
|
+
* Use for routes that require a conjunction of permissions (rare — prefer
|
|
44
|
+
* composing authorize() calls in series if the permissions are independent).
|
|
45
|
+
*
|
|
46
|
+
* Usage:
|
|
47
|
+
* router.post(
|
|
48
|
+
* "/accounts/:id/impersonate",
|
|
49
|
+
* requireAuth,
|
|
50
|
+
* authorizeAll(GlobalPermission.IMPERSONATE_ACCOUNT, GlobalPermission.MANAGE_ACCOUNTS),
|
|
51
|
+
* controller.impersonate,
|
|
52
|
+
* );
|
|
53
|
+
*/
|
|
54
|
+
export declare const authorizeAll: (...permissions: GlobalPermission[]) => (req: Request, res: Response, next: NextFunction) => void;
|
|
@@ -1,45 +1,42 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.authorizeAny = exports.authorize = void 0;
|
|
3
|
+
exports.authorizeAll = exports.authorizeAny = exports.authorize = void 0;
|
|
4
4
|
const internal_jwt_verifier_1 = require("../jwt/internal-jwt-verifier");
|
|
5
|
+
const utils_1 = require("../utils");
|
|
5
6
|
/**
|
|
6
|
-
* AUTHORIZE MIDDLEWARE
|
|
7
|
-
*
|
|
8
|
-
* Permission-gates a route. Must run AFTER
|
|
7
|
+
* AUTHORIZE MIDDLEWARE (@discover-cloud/shared)
|
|
8
|
+
* ───────────────────────────────────────────────
|
|
9
|
+
* Permission-gates a route. Must run AFTER requireAuth middleware, which
|
|
10
|
+
* populates req.accessContext from the verified internal JWT + Redis cache.
|
|
11
|
+
*
|
|
9
12
|
* Uses InternalJwtVerifier.hasPermission — permissions come from the Redis
|
|
10
|
-
* permission cache loaded by RequireAuthMiddleware,
|
|
13
|
+
* permission cache loaded by RequireAuthMiddleware, never from the JWT itself.
|
|
14
|
+
*
|
|
15
|
+
* All error responses go through the shared failure() util to ensure a
|
|
16
|
+
* consistent ApiErrorResponse shape across every service. The error is
|
|
17
|
+
* returned directly (not passed to next()) because these are deterministic
|
|
18
|
+
* middleware guards — there is nothing to recover from downstream.
|
|
11
19
|
*
|
|
12
20
|
* Usage:
|
|
13
|
-
* router.delete(
|
|
21
|
+
* router.delete(
|
|
22
|
+
* "/accounts/:id",
|
|
14
23
|
* requireAuth,
|
|
15
24
|
* authorize(GlobalPermission.DELETE_ACCOUNT),
|
|
16
|
-
* controller.delete
|
|
25
|
+
* controller.delete,
|
|
17
26
|
* );
|
|
18
27
|
*/
|
|
19
28
|
const authorize = (permission) => {
|
|
20
29
|
return (req, res, next) => {
|
|
21
|
-
//
|
|
30
|
+
// Ensure RequireAuthMiddleware has already run and populated accessContext.
|
|
31
|
+
// This is a programming error (wrong middleware order), not a client error,
|
|
32
|
+
// but we surface it as 401 so it doesn't silently pass through.
|
|
22
33
|
if (!req.accessContext) {
|
|
23
|
-
|
|
24
|
-
success: false,
|
|
25
|
-
error: {
|
|
26
|
-
message: "Unauthorized: No access context",
|
|
27
|
-
code: "UNAUTHORIZED",
|
|
28
|
-
requestId: req.id,
|
|
29
|
-
},
|
|
30
|
-
});
|
|
34
|
+
(0, utils_1.failure)(res, req, "Unauthorized: No access context", "UNAUTHORIZED", 401);
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
33
|
-
//
|
|
37
|
+
// Check permission against accessContext.perms (populated from Redis cache).
|
|
34
38
|
if (!internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(req.accessContext, permission)) {
|
|
35
|
-
|
|
36
|
-
success: false,
|
|
37
|
-
error: {
|
|
38
|
-
message: `Forbidden: Missing permission ${permission}`,
|
|
39
|
-
code: "FORBIDDEN",
|
|
40
|
-
requestId: req.id,
|
|
41
|
-
},
|
|
42
|
-
});
|
|
39
|
+
(0, utils_1.failure)(res, req, `Forbidden: Missing permission ${permission}`, "FORBIDDEN", 403);
|
|
43
40
|
return;
|
|
44
41
|
}
|
|
45
42
|
next();
|
|
@@ -49,29 +46,57 @@ exports.authorize = authorize;
|
|
|
49
46
|
/**
|
|
50
47
|
* authorizeAny
|
|
51
48
|
* Passes if the context has AT LEAST ONE of the provided permissions.
|
|
49
|
+
* Useful for routes accessible by multiple roles (e.g. SUPPORT_VIEW or VIEW_ANY_ACCOUNT).
|
|
50
|
+
*
|
|
51
|
+
* Usage:
|
|
52
|
+
* router.get(
|
|
53
|
+
* "/accounts/:id",
|
|
54
|
+
* requireAuth,
|
|
55
|
+
* authorizeAny(GlobalPermission.VIEW_ANY_ACCOUNT, GlobalPermission.SUPPORT_VIEW),
|
|
56
|
+
* controller.getById,
|
|
57
|
+
* );
|
|
52
58
|
*/
|
|
53
59
|
const authorizeAny = (...permissions) => {
|
|
54
60
|
return (req, res, next) => {
|
|
55
61
|
if (!req.accessContext) {
|
|
56
|
-
|
|
57
|
-
success: false,
|
|
58
|
-
error: { message: "Unauthorized: No access context", code: "UNAUTHORIZED", requestId: req.id },
|
|
59
|
-
});
|
|
62
|
+
(0, utils_1.failure)(res, req, "Unauthorized: No access context", "UNAUTHORIZED", 401);
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
62
65
|
const hasAny = permissions.some((p) => internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(req.accessContext, p));
|
|
63
66
|
if (!hasAny) {
|
|
64
|
-
res.
|
|
65
|
-
success: false,
|
|
66
|
-
error: {
|
|
67
|
-
message: `Forbidden: Missing one of [${permissions.join(", ")}]`,
|
|
68
|
-
code: "FORBIDDEN",
|
|
69
|
-
requestId: req.id,
|
|
70
|
-
},
|
|
71
|
-
});
|
|
67
|
+
(0, utils_1.failure)(res, req, `Forbidden: Missing one of [${permissions.join(", ")}]`, "FORBIDDEN", 403);
|
|
72
68
|
return;
|
|
73
69
|
}
|
|
74
70
|
next();
|
|
75
71
|
};
|
|
76
72
|
};
|
|
77
73
|
exports.authorizeAny = authorizeAny;
|
|
74
|
+
/**
|
|
75
|
+
* authorizeAll
|
|
76
|
+
* Passes only if the context has ALL of the provided permissions.
|
|
77
|
+
* Use for routes that require a conjunction of permissions (rare — prefer
|
|
78
|
+
* composing authorize() calls in series if the permissions are independent).
|
|
79
|
+
*
|
|
80
|
+
* Usage:
|
|
81
|
+
* router.post(
|
|
82
|
+
* "/accounts/:id/impersonate",
|
|
83
|
+
* requireAuth,
|
|
84
|
+
* authorizeAll(GlobalPermission.IMPERSONATE_ACCOUNT, GlobalPermission.MANAGE_ACCOUNTS),
|
|
85
|
+
* controller.impersonate,
|
|
86
|
+
* );
|
|
87
|
+
*/
|
|
88
|
+
const authorizeAll = (...permissions) => {
|
|
89
|
+
return (req, res, next) => {
|
|
90
|
+
if (!req.accessContext) {
|
|
91
|
+
(0, utils_1.failure)(res, req, "Unauthorized: No access context", "UNAUTHORIZED", 401);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const missingPermissions = permissions.filter((p) => !internal_jwt_verifier_1.InternalJwtVerifier.hasPermission(req.accessContext, p));
|
|
95
|
+
if (missingPermissions.length > 0) {
|
|
96
|
+
(0, utils_1.failure)(res, req, `Forbidden: Missing permissions [${missingPermissions.join(", ")}]`, "FORBIDDEN", 403);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
next();
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
exports.authorizeAll = authorizeAll;
|