@onlineapps/infrastructure-tools 1.0.70 → 1.1.1
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 +69 -0
- package/package.json +4 -3
- package/src/index.js +9 -1
- package/src/jwt/createJwtValidator.js +62 -0
- package/src/jwt/extractTenantContext.js +64 -0
- package/src/jwt/index.js +11 -0
- package/src/jwt/verifyAccessToken.js +50 -0
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.
|
|
3
|
+
"version": "1.1.1",
|
|
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/infra-logger": "1.0.0",
|
|
21
22
|
"@onlineapps/mq-client-core": "1.0.80",
|
|
22
23
|
"@onlineapps/service-common": "1.0.17",
|
|
23
24
|
"@onlineapps/storage-core": "1.0.12",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
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 };
|
package/src/jwt/index.js
ADDED
|
@@ -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 };
|