@onlineapps/infrastructure-tools 1.0.70 → 1.1.2

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 CHANGED
@@ -92,6 +92,34 @@ await storage.ensureBucket('workflow');
92
92
  await storage.putObject('workflow', 'path/to/file', buffer);
93
93
  ```
94
94
 
95
+ ### JWT Validation
96
+
97
+ Shared JWT validation for infrastructure services accepting client requests (gateway, delivery endpoint, meta).
98
+ Standard: [docs/standards/JWT_AUTH.md](/docs/standards/JWT_AUTH.md).
99
+
100
+ ```javascript
101
+ const {
102
+ createJwtValidator,
103
+ verifyAccessToken,
104
+ extractTenantContext
105
+ } = require('@onlineapps/infrastructure-tools');
106
+
107
+ // Express middleware: validates Bearer token, sets req.auth
108
+ app.use('/api', createJwtValidator({
109
+ logger,
110
+ excludePaths: ['/health']
111
+ }));
112
+
113
+ // Pure function: verify token outside Express context (e.g. WebSocket handshake)
114
+ const decoded = verifyAccessToken(rawToken);
115
+
116
+ // Pure function: extract tenant context from auth + headers
117
+ const ctx = extractTenantContext(req.auth, req.headers);
118
+ // => { tenant_id, tenant_uuid, workspace_id, person_id, person_uuid, role }
119
+ ```
120
+
121
+ **Env:** `JWT_SECRET` (min 16 chars, HMAC-SHA256). Fail-fast if missing.
122
+
95
123
  ## API
96
124
 
97
125
  ### `waitForInfrastructureReady(options)`
@@ -142,6 +170,46 @@ Creates health publisher adapter for BaseClient (from mq-client-core).
142
170
 
143
171
  Creates health publisher adapter for amqplib (direct connection + channel).
144
172
 
173
+ ### `verifyAccessToken(token)`
174
+
175
+ Verify and decode an OA Drive access token (pure function).
176
+
177
+ **Parameters:**
178
+ - `token` (string): Raw JWT string (without "Bearer " prefix)
179
+
180
+ **Returns:** Decoded payload `{ sub, person_id, email, tenants, type, iss, iat, exp }`
181
+
182
+ **Throws:**
183
+ - `TokenExpiredError` (from jsonwebtoken) if token is expired
184
+ - `JsonWebTokenError` if signature or issuer invalid
185
+ - `Error` with `code: 'INVALID_TOKEN_TYPE'` if token type is not `access`
186
+ - `Error` if `JWT_SECRET` env var is missing or too short
187
+
188
+ ### `createJwtValidator({ logger, excludePaths })`
189
+
190
+ Create Express middleware that validates JWT Bearer tokens.
191
+
192
+ **Options:**
193
+ - `logger` (Object): Logger with `.warn()` method (required)
194
+ - `excludePaths` (string[]): Paths to skip validation (default: `[]`)
195
+
196
+ **Behavior:** Sets `req.auth = { person_uuid, person_id, email, tenants }` on success. Returns 401 JSON on failure.
197
+
198
+ ### `extractTenantContext(auth, headers)`
199
+
200
+ Extract tenant context from JWT auth data and request headers (pure function).
201
+
202
+ **Parameters:**
203
+ - `auth` (Object): `req.auth` from `createJwtValidator`: `{ person_uuid, person_id, email, tenants }`
204
+ - `headers` (Object): HTTP request headers (lowercase keys)
205
+
206
+ **Returns:** `{ tenant_id, tenant_uuid, workspace_id, person_id, person_uuid, role }`
207
+
208
+ **Throws:**
209
+ - `Error` with `statusCode: 400` if multiple tenants and `x-tenant-uuid` header missing
210
+ - `Error` with `statusCode: 403` if specified tenant not in memberships
211
+ - `Error` with `statusCode: 401` if auth data is missing
212
+
145
213
  ### `StorageCore`
146
214
 
147
215
  Re-exported from `@onlineapps/storage-core` for infrastructure services.
@@ -162,6 +230,7 @@ See [@onlineapps/storage-core](../storage/storage-core/README.md) for full API d
162
230
  - `@onlineapps/mq-client-core` - For queue configuration
163
231
  - `@onlineapps/service-common` - For `waitForInfrastructureReady` (shared utility)
164
232
  - `@onlineapps/storage-core` - For core MinIO storage operations
233
+ - `jsonwebtoken` - For JWT verification (HMAC-SHA256)
165
234
 
166
235
  ## Related Libraries
167
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/infrastructure-tools",
3
- "version": "1.0.70",
3
+ "version": "1.1.2",
4
4
  "description": "Infrastructure orchestration utilities for OA Drive infrastructure services (health tracking, queue initialization, service discovery)",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -18,11 +18,12 @@
18
18
  "author": "OnlineApps",
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "@onlineapps/mq-client-core": "1.0.80",
21
+ "@onlineapps/infra-logger": "1.0.0",
22
+ "@onlineapps/mq-client-core": "1.0.81",
22
23
  "@onlineapps/service-common": "1.0.17",
23
24
  "@onlineapps/storage-core": "1.0.12",
24
- "uuid": "^9.0.1",
25
- "@onlineapps/infra-logger": "1.0.0"
25
+ "jsonwebtoken": "^9.0.3",
26
+ "uuid": "^9.0.1"
26
27
  },
27
28
  "devDependencies": {
28
29
  "jest": "^29.7.0"
package/src/index.js CHANGED
@@ -52,6 +52,9 @@ const {
52
52
  } = require('./health/healthPublisher');
53
53
  const { sendQueueMismatchAlert } = require('./monitoring/queueMismatchReporter');
54
54
 
55
+ // JWT validation utilities (shared across infrastructure services that accept client requests)
56
+ const { verifyAccessToken, createJwtValidator, extractTenantContext } = require('./jwt');
57
+
55
58
  module.exports = {
56
59
  // MQ Client Core (re-exported for convenience)
57
60
  BaseClient,
@@ -95,6 +98,11 @@ module.exports = {
95
98
  sendQueueMismatchAlert,
96
99
 
97
100
  // Storage utilities (re-exported - infrastructure services should use infrastructure-tools, not storage-core directly)
98
- StorageCore
101
+ StorageCore,
102
+
103
+ // JWT validation utilities (for infrastructure services accepting client HTTP/WS requests)
104
+ verifyAccessToken,
105
+ createJwtValidator,
106
+ extractTenantContext
99
107
  };
100
108
 
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const { verifyAccessToken } = require('./verifyAccessToken');
4
+
5
+ /**
6
+ * Create Express middleware that validates JWT Bearer tokens on incoming requests.
7
+ *
8
+ * On success: sets req.auth = { person_uuid, person_id, email, tenants }.
9
+ * On failure: responds with 401 JSON.
10
+ * Paths listed in excludePaths are skipped (transparent pass-through).
11
+ *
12
+ * @param {object} options
13
+ * @param {object} options.logger - Logger with .warn() method (required)
14
+ * @param {string[]} [options.excludePaths] - Paths to skip JWT validation
15
+ * @returns {Function} Express middleware (req, res, next)
16
+ */
17
+ function createJwtValidator({ logger, excludePaths }) {
18
+ if (!logger || typeof logger.warn !== 'function') {
19
+ throw new Error('[JWT] Logger is required - Expected object with warn() method');
20
+ }
21
+
22
+ const excluded = new Set(excludePaths || []);
23
+
24
+ return function jwtValidator(req, res, next) {
25
+ if (excluded.has(req.path)) {
26
+ return next();
27
+ }
28
+
29
+ const authHeader = req.headers.authorization;
30
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
31
+ return res.status(401).json({
32
+ error: 'Missing or invalid Authorization header - Expected: Bearer <token>'
33
+ });
34
+ }
35
+
36
+ const token = authHeader.slice(7);
37
+
38
+ try {
39
+ const decoded = verifyAccessToken(token);
40
+
41
+ req.auth = {
42
+ person_uuid: decoded.sub,
43
+ person_id: decoded.person_id,
44
+ email: decoded.email,
45
+ tenants: decoded.tenants || []
46
+ };
47
+
48
+ next();
49
+ } catch (err) {
50
+ if (err.name === 'TokenExpiredError') {
51
+ return res.status(401).json({ error: 'Token expired - refresh your access token' });
52
+ }
53
+ if (err.code === 'INVALID_TOKEN_TYPE') {
54
+ return res.status(401).json({ error: 'Invalid token type - Expected access token' });
55
+ }
56
+ logger.warn('[JWT] Token verification failed', { error: err.message });
57
+ return res.status(401).json({ error: 'Invalid token' });
58
+ }
59
+ };
60
+ }
61
+
62
+ module.exports = { createJwtValidator };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Extract tenant context from decoded JWT auth data and request headers.
5
+ *
6
+ * Pure function — no Express dependency. Callers (middleware) translate
7
+ * thrown errors into HTTP responses.
8
+ *
9
+ * Rules (per JWT_AUTH.md section 7):
10
+ * - Single tenant: auto-pick, x-tenant-uuid header optional
11
+ * - Multiple tenants: x-tenant-uuid header required
12
+ * - workspace_id defaults to 1 if header absent
13
+ *
14
+ * @param {object} auth - req.auth from createJwtValidator: { person_uuid, person_id, email, tenants }
15
+ * @param {object} headers - HTTP request headers (lowercase keys)
16
+ * @returns {object} { tenant_id, tenant_uuid, workspace_id, person_id, person_uuid, role }
17
+ * @throws {Error} with .statusCode = 400 or 403
18
+ */
19
+ function extractTenantContext(auth, headers) {
20
+ if (!auth || !Array.isArray(auth.tenants)) {
21
+ const err = new Error('[TenantContext] Missing auth data - Expected auth object with tenants array');
22
+ err.statusCode = 401;
23
+ throw err;
24
+ }
25
+
26
+ const tenantUuid = headers['x-tenant-uuid'];
27
+ const rawWorkspaceId = headers['x-workspace-id'];
28
+ const workspaceId = rawWorkspaceId ? parseInt(rawWorkspaceId, 10) : 1;
29
+
30
+ if (tenantUuid) {
31
+ const membership = auth.tenants.find(t => t.tenant_uuid === tenantUuid);
32
+ if (!membership) {
33
+ const err = new Error('Access denied - No membership for specified tenant');
34
+ err.statusCode = 403;
35
+ throw err;
36
+ }
37
+ return {
38
+ tenant_id: membership.tenant_id,
39
+ tenant_uuid: membership.tenant_uuid,
40
+ workspace_id: workspaceId,
41
+ person_id: auth.person_id,
42
+ person_uuid: auth.person_uuid,
43
+ role: membership.role
44
+ };
45
+ }
46
+
47
+ if (auth.tenants.length === 1) {
48
+ const t = auth.tenants[0];
49
+ return {
50
+ tenant_id: t.tenant_id,
51
+ tenant_uuid: t.tenant_uuid,
52
+ workspace_id: workspaceId,
53
+ person_id: auth.person_id,
54
+ person_uuid: auth.person_uuid,
55
+ role: t.role
56
+ };
57
+ }
58
+
59
+ const err = new Error('Missing tenant context - Provide x-tenant-uuid header (multiple tenants available)');
60
+ err.statusCode = 400;
61
+ throw err;
62
+ }
63
+
64
+ module.exports = { extractTenantContext };
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ const { verifyAccessToken } = require('./verifyAccessToken');
4
+ const { createJwtValidator } = require('./createJwtValidator');
5
+ const { extractTenantContext } = require('./extractTenantContext');
6
+
7
+ module.exports = {
8
+ verifyAccessToken,
9
+ createJwtValidator,
10
+ extractTenantContext
11
+ };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const jwt = require('jsonwebtoken');
4
+
5
+ const ISSUER = 'oa-auth';
6
+ const MIN_SECRET_LENGTH = 16;
7
+
8
+ /**
9
+ * Resolve JWT_SECRET from environment. Fail-fast on missing/insecure value.
10
+ * @returns {string}
11
+ */
12
+ function getJwtSecret() {
13
+ const secret = process.env.JWT_SECRET;
14
+ if (!secret || secret.length < MIN_SECRET_LENGTH) {
15
+ throw new Error(
16
+ `[JWT] Missing or insecure JWT_SECRET - Expected env var with at least ${MIN_SECRET_LENGTH} characters`
17
+ );
18
+ }
19
+ return secret;
20
+ }
21
+
22
+ /**
23
+ * Verify and decode an OA Drive access token.
24
+ *
25
+ * Validates: signature (HMAC-SHA256), issuer ('oa-auth'), expiry, type ('access').
26
+ * Returns the full decoded payload on success.
27
+ * Throws on any validation failure — callers decide how to handle.
28
+ *
29
+ * @param {string} token - Raw JWT string (without "Bearer " prefix)
30
+ * @returns {object} Decoded payload: { sub, person_id, email, tenants, type, iss, iat, exp }
31
+ * @throws {Error} TokenExpiredError, JsonWebTokenError, or type mismatch
32
+ */
33
+ function verifyAccessToken(token) {
34
+ if (!token || typeof token !== 'string') {
35
+ throw new Error('[JWT] Token is required - Expected non-empty string');
36
+ }
37
+
38
+ const secret = getJwtSecret();
39
+ const decoded = jwt.verify(token, secret, { issuer: ISSUER });
40
+
41
+ if (decoded.type !== 'access') {
42
+ const err = new Error(`[JWT] Invalid token type - Expected 'access', got '${decoded.type}'`);
43
+ err.code = 'INVALID_TOKEN_TYPE';
44
+ throw err;
45
+ }
46
+
47
+ return decoded;
48
+ }
49
+
50
+ module.exports = { verifyAccessToken, getJwtSecret, ISSUER, MIN_SECRET_LENGTH };