@oxyhq/core 1.11.13 → 1.11.14
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/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +5 -2
- package/dist/cjs/mixins/OxyServices.auth.js +133 -27
- package/dist/cjs/mixins/OxyServices.utility.js +405 -75
- package/dist/cjs/utils/languageUtils.js +22 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +131 -27
- package/dist/esm/mixins/OxyServices.utility.js +405 -75
- package/dist/esm/utils/languageUtils.js +21 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +14 -4
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +72 -11
- package/dist/types/mixins/OxyServices.utility.d.ts +144 -10
- package/dist/types/utils/languageUtils.d.ts +1 -0
- package/package.json +18 -2
- package/src/OxyServices.ts +17 -3
- package/src/index.ts +3 -1
- package/src/mixins/OxyServices.auth.ts +160 -28
- package/src/mixins/OxyServices.utility.ts +551 -87
- package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
- package/src/utils/languageUtils.ts +23 -2
|
@@ -9,13 +9,57 @@ exports.OxyServicesUtilityMixin = OxyServicesUtilityMixin;
|
|
|
9
9
|
*/
|
|
10
10
|
const jwt_decode_1 = require("jwt-decode");
|
|
11
11
|
const platformCrypto_1 = require("../utils/platformCrypto");
|
|
12
|
+
const loggerUtils_1 = require("../utils/loggerUtils");
|
|
12
13
|
const mixinHelpers_1 = require("./mixinHelpers");
|
|
14
|
+
/**
|
|
15
|
+
* Expected JWT audience for tokens issued by the Oxy auth service.
|
|
16
|
+
*/
|
|
17
|
+
const OXY_JWT_AUDIENCE = 'oxy-api';
|
|
18
|
+
/**
|
|
19
|
+
* Expected JWT issuer for tokens issued by the Oxy auth service.
|
|
20
|
+
*/
|
|
21
|
+
const OXY_JWT_ISSUER = 'oxy-auth';
|
|
22
|
+
/**
|
|
23
|
+
* Sentinel error classes for service-token verification. Using classes (not
|
|
24
|
+
* message strings) makes the catch site below safe to extend: a new failure
|
|
25
|
+
* mode added later cannot silently fall through to the generic 500 branch.
|
|
26
|
+
*/
|
|
27
|
+
class ServiceTokenStructureError extends Error {
|
|
28
|
+
constructor(message = 'Service token has malformed structure') {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'ServiceTokenStructureError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
class ServiceTokenSignatureError extends Error {
|
|
34
|
+
constructor(message = 'Service token signature is invalid') {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'ServiceTokenSignatureError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class ServiceTokenClaimError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'ServiceTokenClaimError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
13
45
|
function OxyServicesUtilityMixin(Base) {
|
|
14
46
|
return class extends Base {
|
|
47
|
+
// TypeScript's mixin pattern requires `(...args: any[])` here — the
|
|
48
|
+
// constructor signature is a structural shape check the compiler enforces.
|
|
49
|
+
// Matches every other mixin in this package; do not change without a
|
|
50
|
+
// monorepo-wide refactor of the mixin pipeline.
|
|
15
51
|
constructor(...args) {
|
|
16
52
|
super(...args);
|
|
17
53
|
/** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
|
|
18
54
|
this._actingAsCache = new Map();
|
|
55
|
+
/**
|
|
56
|
+
* In-memory cache for service-acting-as verification.
|
|
57
|
+
* Negative results are cached for 1min to avoid hammering the verify
|
|
58
|
+
* endpoint when a service is misconfigured; positive grants are cached
|
|
59
|
+
* for 5min to amortize the round-trip without holding stale grants too long.
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
this._serviceActingAsCache = new Map();
|
|
19
63
|
}
|
|
20
64
|
/**
|
|
21
65
|
* Verify that a user is authorized to act as a managed account.
|
|
@@ -41,7 +85,13 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
41
85
|
});
|
|
42
86
|
return result && result.authorized ? result : null;
|
|
43
87
|
}
|
|
44
|
-
catch {
|
|
88
|
+
catch (error) {
|
|
89
|
+
loggerUtils_1.logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
|
|
90
|
+
component: 'auth',
|
|
91
|
+
method: 'verifyActingAs',
|
|
92
|
+
userId,
|
|
93
|
+
accountId,
|
|
94
|
+
}, error);
|
|
45
95
|
// Cache negative result for 1 minute to avoid hammering on transient errors
|
|
46
96
|
this._actingAsCache.set(cacheKey, {
|
|
47
97
|
result: null,
|
|
@@ -50,6 +100,53 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
50
100
|
return null;
|
|
51
101
|
}
|
|
52
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Verify that a service app holds an active delegation grant authorising
|
|
105
|
+
* it to act on behalf of `userId`. Returns the grant (with allowed scopes)
|
|
106
|
+
* on success or `null` if no valid grant exists. Negative answers are
|
|
107
|
+
* cached briefly to protect the verify endpoint from misconfigured callers.
|
|
108
|
+
*
|
|
109
|
+
* Implemented as a per-instance Map keyed by `appId:userId`. Cached
|
|
110
|
+
* positive grants live for 5 minutes (acceptable staleness window for an
|
|
111
|
+
* impersonation grant); revocations propagate within that window.
|
|
112
|
+
*
|
|
113
|
+
* @internal Used by the auth() middleware — not part of the public API
|
|
114
|
+
*/
|
|
115
|
+
async verifyServiceActingAs(appId, userId) {
|
|
116
|
+
const cacheKey = `${appId}:${userId}`;
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const cached = this._serviceActingAsCache.get(cacheKey);
|
|
119
|
+
if (cached && cached.expiresAt > now) {
|
|
120
|
+
return cached.result;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, { cache: false, retry: false, timeout: 5000 });
|
|
124
|
+
const authorized = Boolean(result && result.authorized);
|
|
125
|
+
const verified = authorized
|
|
126
|
+
? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
|
|
127
|
+
: null;
|
|
128
|
+
this._serviceActingAsCache.set(cacheKey, {
|
|
129
|
+
result: verified,
|
|
130
|
+
expiresAt: now + 5 * 60 * 1000,
|
|
131
|
+
});
|
|
132
|
+
return verified;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
loggerUtils_1.logger.warn('[oxy.auth] verifyServiceActingAs lookup failed — caching negative result', {
|
|
136
|
+
component: 'auth',
|
|
137
|
+
method: 'verifyServiceActingAs',
|
|
138
|
+
appId,
|
|
139
|
+
userId,
|
|
140
|
+
}, error);
|
|
141
|
+
// Negative cache prevents a runaway loop if the verify endpoint is
|
|
142
|
+
// down, while still letting a real grant become visible within 60s.
|
|
143
|
+
this._serviceActingAsCache.set(cacheKey, {
|
|
144
|
+
result: null,
|
|
145
|
+
expiresAt: now + 1 * 60 * 1000,
|
|
146
|
+
});
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
53
150
|
/**
|
|
54
151
|
* Fetch link metadata
|
|
55
152
|
*/
|
|
@@ -77,9 +174,17 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
77
174
|
* - Security comes from API-based session validation (`validateSession()`)
|
|
78
175
|
* which checks the session server-side on every request
|
|
79
176
|
* - Service tokens (type: 'service') DO use cryptographic HMAC verification
|
|
80
|
-
* via the `jwtSecret` option, since they are stateless
|
|
177
|
+
* via the `jwtSecret` option, since they are stateless. Service tokens
|
|
178
|
+
* are additionally checked for `aud`, `iss`, and `type` claims to prevent
|
|
179
|
+
* cross-token-type confusion attacks.
|
|
81
180
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
82
|
-
* direct access to `ACCESS_TOKEN_SECRET
|
|
181
|
+
* direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
|
|
182
|
+
*
|
|
183
|
+
* **Service-token delegation (X-Oxy-User-Id):**
|
|
184
|
+
* When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
|
|
185
|
+
* `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
|
|
186
|
+
* grant exists before attaching `req.userId`. A missing/expired grant
|
|
187
|
+
* results in a 403 — there is no fail-open path.
|
|
83
188
|
*
|
|
84
189
|
* @example
|
|
85
190
|
* ```typescript
|
|
@@ -88,7 +193,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
88
193
|
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
89
194
|
*
|
|
90
195
|
* // Protect all routes under /protected
|
|
91
|
-
* app.use('/protected', oxy.auth());
|
|
196
|
+
* app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
|
|
92
197
|
*
|
|
93
198
|
* // Access user in route handler
|
|
94
199
|
* app.get('/protected/me', (req, res) => {
|
|
@@ -100,14 +205,18 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
100
205
|
*
|
|
101
206
|
* // Optional auth - attach user if present, don't block if absent
|
|
102
207
|
* app.use('/public', oxy.auth({ optional: true }));
|
|
208
|
+
*
|
|
209
|
+
* // Require a specific scope on a service-token-protected route
|
|
210
|
+
* app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
|
|
103
211
|
* ```
|
|
104
212
|
*
|
|
105
213
|
* @param options Optional configuration
|
|
106
214
|
* @returns Express middleware function
|
|
107
215
|
*/
|
|
108
216
|
auth(options = {}) {
|
|
109
|
-
const { debug = false, onError, loadUser = false, optional = false, jwtSecret } = options;
|
|
110
|
-
//
|
|
217
|
+
const { debug = false, onError, loadUser = false, optional = false, jwtSecret, expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
|
|
218
|
+
// Cross-mixin method access: typed as a structural subset of the
|
|
219
|
+
// composed OxyServices we know we have at runtime.
|
|
111
220
|
const oxyInstance = this;
|
|
112
221
|
// Return an async middleware function
|
|
113
222
|
return async (req, res, next) => {
|
|
@@ -117,9 +226,12 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
117
226
|
// the managed account, preserving the original user for audit trails.
|
|
118
227
|
const processActingAs = async () => {
|
|
119
228
|
const actingAsUserId = req.headers['x-acting-as'];
|
|
120
|
-
if (!actingAsUserId)
|
|
229
|
+
if (!actingAsUserId || typeof actingAsUserId !== 'string')
|
|
121
230
|
return true; // No header, proceed normally
|
|
122
|
-
const
|
|
231
|
+
const currentUserId = req.userId;
|
|
232
|
+
if (!currentUserId)
|
|
233
|
+
return true; // No authenticated user yet — nothing to swap
|
|
234
|
+
const verification = await oxyInstance.verifyActingAs(currentUserId, actingAsUserId);
|
|
123
235
|
if (!verification) {
|
|
124
236
|
const error = {
|
|
125
237
|
error: 'ACTING_AS_UNAUTHORIZED',
|
|
@@ -136,23 +248,25 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
136
248
|
return false;
|
|
137
249
|
}
|
|
138
250
|
// Preserve original user for audit trails
|
|
139
|
-
req.originalUser = { id:
|
|
251
|
+
req.originalUser = { id: currentUserId, ...(req.user ?? {}) };
|
|
140
252
|
req.actingAs = { userId: actingAsUserId, role: verification.role };
|
|
141
253
|
// Swap user identity to the managed account
|
|
142
254
|
req.userId = actingAsUserId;
|
|
143
|
-
req.user = { id: actingAsUserId };
|
|
144
|
-
// Also set _id for routes that use Pattern B (req.user._id)
|
|
145
|
-
if (req.user) {
|
|
146
|
-
req.user._id = actingAsUserId;
|
|
147
|
-
}
|
|
255
|
+
req.user = { id: actingAsUserId, _id: actingAsUserId };
|
|
148
256
|
if (debug) {
|
|
149
|
-
|
|
257
|
+
loggerUtils_1.logger.debug(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${currentUserId}`, {
|
|
258
|
+
component: 'auth',
|
|
259
|
+
method: 'auth.processActingAs',
|
|
260
|
+
});
|
|
150
261
|
}
|
|
151
262
|
return true;
|
|
152
263
|
};
|
|
153
264
|
try {
|
|
154
|
-
// Extract token from Authorization header or query params
|
|
155
|
-
|
|
265
|
+
// Extract token from Authorization header or query params.
|
|
266
|
+
// Node/Express normalizes `Authorization` to a string; we guard
|
|
267
|
+
// against the (legal but unusual) string[] case anyway.
|
|
268
|
+
const rawAuthHeader = req.headers.authorization;
|
|
269
|
+
const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader;
|
|
156
270
|
let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
|
157
271
|
// Fallback to query params (useful for WebSocket upgrades)
|
|
158
272
|
if (!token) {
|
|
@@ -163,7 +277,10 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
163
277
|
token = q.access_token;
|
|
164
278
|
}
|
|
165
279
|
if (debug) {
|
|
166
|
-
|
|
280
|
+
loggerUtils_1.logger.debug(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`, {
|
|
281
|
+
component: 'auth',
|
|
282
|
+
method: 'auth',
|
|
283
|
+
});
|
|
167
284
|
}
|
|
168
285
|
if (!token) {
|
|
169
286
|
if (optional) {
|
|
@@ -187,6 +304,12 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
187
304
|
decoded = (0, jwt_decode_1.jwtDecode)(token);
|
|
188
305
|
}
|
|
189
306
|
catch (decodeError) {
|
|
307
|
+
if (debug) {
|
|
308
|
+
loggerUtils_1.logger.debug('[oxy.auth] Token decode failed', {
|
|
309
|
+
component: 'auth',
|
|
310
|
+
method: 'auth',
|
|
311
|
+
}, decodeError);
|
|
312
|
+
}
|
|
190
313
|
if (optional) {
|
|
191
314
|
req.userId = null;
|
|
192
315
|
req.user = null;
|
|
@@ -222,51 +345,69 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
222
345
|
return onError(error);
|
|
223
346
|
return res.status(403).json(error);
|
|
224
347
|
}
|
|
225
|
-
// Verify JWT signature
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
348
|
+
// Verify JWT signature, then audience / issuer / type / appId claims.
|
|
349
|
+
//
|
|
350
|
+
// Signature verification uses a manual HMAC-SHA256 compare because
|
|
351
|
+
// this file ships into RN/web bundles where `jsonwebtoken` is
|
|
352
|
+
// unavailable. The middleware only ever runs on Node hosts (see
|
|
353
|
+
// platformCrypto's doc-comment), and `loadNodeCrypto` is per-
|
|
354
|
+
// platform: the RN variant throws so Metro never bundles a Node
|
|
355
|
+
// built-in reference.
|
|
231
356
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
const expectedSig = createHmac('sha256', jwtSecret)
|
|
239
|
-
.update(`${headerB64}.${payloadB64}`)
|
|
240
|
-
.digest('base64')
|
|
241
|
-
.replace(/\+/g, '-')
|
|
242
|
-
.replace(/\//g, '_')
|
|
243
|
-
.replace(/=/g, '');
|
|
244
|
-
// Timing-safe comparison
|
|
245
|
-
const sigBuf = Buffer.from(signatureB64);
|
|
246
|
-
const expectedBuf = Buffer.from(expectedSig);
|
|
247
|
-
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
248
|
-
throw new Error('Invalid signature');
|
|
249
|
-
}
|
|
357
|
+
await verifyServiceTokenSignature(token, jwtSecret);
|
|
358
|
+
verifyServiceTokenClaims(decoded, {
|
|
359
|
+
audience: expectedAudience,
|
|
360
|
+
issuer: expectedIssuer,
|
|
361
|
+
});
|
|
250
362
|
}
|
|
251
363
|
catch (verifyError) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
364
|
+
// Structure + signature + claim errors all map to 401. Anything
|
|
365
|
+
// else (e.g. Node crypto failing to load on a misconfigured host)
|
|
366
|
+
// genuinely IS a 500.
|
|
367
|
+
if (verifyError instanceof ServiceTokenStructureError ||
|
|
368
|
+
verifyError instanceof ServiceTokenSignatureError ||
|
|
369
|
+
verifyError instanceof ServiceTokenClaimError) {
|
|
370
|
+
if (debug) {
|
|
371
|
+
loggerUtils_1.logger.debug('[oxy.auth] Service token rejected', {
|
|
372
|
+
component: 'auth',
|
|
373
|
+
method: 'auth.serviceToken',
|
|
374
|
+
reason: verifyError.name,
|
|
375
|
+
detail: verifyError.message,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (optional) {
|
|
379
|
+
req.userId = null;
|
|
380
|
+
req.user = null;
|
|
381
|
+
return next();
|
|
382
|
+
}
|
|
383
|
+
const code = verifyError instanceof ServiceTokenSignatureError
|
|
384
|
+
? 'INVALID_SERVICE_TOKEN'
|
|
385
|
+
: verifyError instanceof ServiceTokenStructureError
|
|
386
|
+
? 'INVALID_SERVICE_TOKEN'
|
|
387
|
+
: 'INVALID_SERVICE_TOKEN_CLAIMS';
|
|
388
|
+
const error = {
|
|
389
|
+
error: code,
|
|
390
|
+
message: verifyError.message,
|
|
391
|
+
code,
|
|
392
|
+
status: 401,
|
|
393
|
+
};
|
|
257
394
|
if (onError)
|
|
258
395
|
return onError(error);
|
|
259
|
-
return res.status(
|
|
260
|
-
}
|
|
261
|
-
if (optional) {
|
|
262
|
-
req.userId = null;
|
|
263
|
-
req.user = null;
|
|
264
|
-
return next();
|
|
396
|
+
return res.status(401).json(error);
|
|
265
397
|
}
|
|
266
|
-
|
|
398
|
+
loggerUtils_1.logger.error('[oxy.auth] Unexpected error during service token verification', verifyError, {
|
|
399
|
+
component: 'auth',
|
|
400
|
+
method: 'auth.serviceToken',
|
|
401
|
+
});
|
|
402
|
+
const error = {
|
|
403
|
+
error: 'AUTH_INTERNAL_ERROR',
|
|
404
|
+
message: 'Internal authentication error',
|
|
405
|
+
code: 'AUTH_INTERNAL_ERROR',
|
|
406
|
+
status: 500,
|
|
407
|
+
};
|
|
267
408
|
if (onError)
|
|
268
409
|
return onError(error);
|
|
269
|
-
return res.status(
|
|
410
|
+
return res.status(500).json(error);
|
|
270
411
|
}
|
|
271
412
|
// Check expiration — reject tokens at exact expiry second (use <=)
|
|
272
413
|
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
@@ -281,7 +422,8 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
281
422
|
return res.status(401).json(error);
|
|
282
423
|
}
|
|
283
424
|
// Validate required service token fields
|
|
284
|
-
|
|
425
|
+
const appId = decoded.appId;
|
|
426
|
+
if (!appId) {
|
|
285
427
|
if (optional) {
|
|
286
428
|
req.userId = null;
|
|
287
429
|
req.user = null;
|
|
@@ -293,16 +435,52 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
293
435
|
return res.status(401).json(error);
|
|
294
436
|
}
|
|
295
437
|
// Read delegated user ID from header
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
438
|
+
const oxyUserIdRaw = req.headers['x-oxy-user-id'];
|
|
439
|
+
const oxyUserId = typeof oxyUserIdRaw === 'string' && oxyUserIdRaw.length > 0 ? oxyUserIdRaw : null;
|
|
440
|
+
// C3: a service may only act as a user when an explicit
|
|
441
|
+
// ServiceActingAs grant exists for that (appId, userId) pair.
|
|
442
|
+
// Without the grant we MUST refuse — silently attaching
|
|
443
|
+
// `req.userId = oxyUserId` would let any service impersonate
|
|
444
|
+
// any user simply by setting the header.
|
|
445
|
+
if (oxyUserId) {
|
|
446
|
+
const grant = await oxyInstance.verifyServiceActingAs(appId, oxyUserId);
|
|
447
|
+
if (!grant || !grant.authorized) {
|
|
448
|
+
loggerUtils_1.logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
|
|
449
|
+
component: 'auth',
|
|
450
|
+
method: 'auth.serviceToken',
|
|
451
|
+
appId,
|
|
452
|
+
attemptedUserId: oxyUserId,
|
|
453
|
+
});
|
|
454
|
+
const error = {
|
|
455
|
+
error: 'SERVICE_ACTING_AS_UNAUTHORIZED',
|
|
456
|
+
message: 'Service not authorized to act as this user',
|
|
457
|
+
code: 'SERVICE_ACTING_AS_UNAUTHORIZED',
|
|
458
|
+
status: 403,
|
|
459
|
+
};
|
|
460
|
+
if (onError)
|
|
461
|
+
return onError(error);
|
|
462
|
+
return res.status(403).json(error);
|
|
463
|
+
}
|
|
464
|
+
req.userId = oxyUserId;
|
|
465
|
+
req.user = { id: oxyUserId };
|
|
466
|
+
req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
// No X-Oxy-User-Id means the service is acting as itself.
|
|
470
|
+
req.userId = null;
|
|
471
|
+
req.user = null;
|
|
472
|
+
}
|
|
299
473
|
req.accessToken = token;
|
|
300
474
|
req.serviceApp = {
|
|
301
|
-
appId
|
|
475
|
+
appId,
|
|
302
476
|
appName: decoded.appName || 'unknown',
|
|
477
|
+
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
303
478
|
};
|
|
304
479
|
if (debug) {
|
|
305
|
-
|
|
480
|
+
loggerUtils_1.logger.debug(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`, {
|
|
481
|
+
component: 'auth',
|
|
482
|
+
method: 'auth.serviceToken',
|
|
483
|
+
});
|
|
306
484
|
}
|
|
307
485
|
return next();
|
|
308
486
|
}
|
|
@@ -376,7 +554,10 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
376
554
|
req.user = { id: userId };
|
|
377
555
|
}
|
|
378
556
|
if (debug) {
|
|
379
|
-
|
|
557
|
+
loggerUtils_1.logger.debug(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`, {
|
|
558
|
+
component: 'auth',
|
|
559
|
+
method: 'auth',
|
|
560
|
+
});
|
|
380
561
|
}
|
|
381
562
|
// Process X-Acting-As header before proceeding
|
|
382
563
|
if (await processActingAs())
|
|
@@ -385,7 +566,10 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
385
566
|
}
|
|
386
567
|
catch (validationError) {
|
|
387
568
|
if (debug) {
|
|
388
|
-
|
|
569
|
+
loggerUtils_1.logger.debug('[oxy.auth] Session validation failed', {
|
|
570
|
+
component: 'auth',
|
|
571
|
+
method: 'auth',
|
|
572
|
+
}, validationError);
|
|
389
573
|
}
|
|
390
574
|
if (optional) {
|
|
391
575
|
req.userId = null;
|
|
@@ -425,25 +609,44 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
425
609
|
req.user = fullUser;
|
|
426
610
|
}
|
|
427
611
|
}
|
|
428
|
-
catch {
|
|
429
|
-
//
|
|
612
|
+
catch (loadUserError) {
|
|
613
|
+
// Loading the full user is best-effort here; the basic { id }
|
|
614
|
+
// object is already attached. Log so misconfigured deployments
|
|
615
|
+
// can be diagnosed instead of silently failing.
|
|
616
|
+
loggerUtils_1.logger.warn('[oxy.auth] loadUser fallback — could not fetch full profile', {
|
|
617
|
+
component: 'auth',
|
|
618
|
+
method: 'auth.loadUser',
|
|
619
|
+
userId,
|
|
620
|
+
}, loadUserError);
|
|
430
621
|
}
|
|
431
622
|
}
|
|
432
623
|
if (debug) {
|
|
433
|
-
|
|
624
|
+
loggerUtils_1.logger.debug(`[oxy.auth] OK user=${userId} (no session)`, {
|
|
625
|
+
component: 'auth',
|
|
626
|
+
method: 'auth',
|
|
627
|
+
});
|
|
434
628
|
}
|
|
435
629
|
// Process X-Acting-As header before proceeding
|
|
436
630
|
if (await processActingAs())
|
|
437
631
|
next();
|
|
438
632
|
}
|
|
439
633
|
catch (error) {
|
|
440
|
-
const
|
|
634
|
+
const handled = oxyInstance.handleError(error);
|
|
635
|
+
const apiError = {
|
|
636
|
+
message: handled.message || 'Authentication error',
|
|
637
|
+
code: handled.code ?? 'AUTH_ERROR',
|
|
638
|
+
status: handled.status ?? 500,
|
|
639
|
+
details: handled.details,
|
|
640
|
+
};
|
|
441
641
|
if (debug) {
|
|
442
|
-
|
|
642
|
+
loggerUtils_1.logger.debug('[oxy.auth] Error', {
|
|
643
|
+
component: 'auth',
|
|
644
|
+
method: 'auth',
|
|
645
|
+
}, apiError);
|
|
443
646
|
}
|
|
444
647
|
if (onError)
|
|
445
648
|
return onError(apiError);
|
|
446
|
-
return res.status(
|
|
649
|
+
return res.status(apiError.status).json(apiError);
|
|
447
650
|
}
|
|
448
651
|
};
|
|
449
652
|
}
|
|
@@ -473,7 +676,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
473
676
|
*/
|
|
474
677
|
authSocket(options = {}) {
|
|
475
678
|
const { debug = false } = options;
|
|
476
|
-
//
|
|
679
|
+
// Cross-mixin method access typed via the same structural subset.
|
|
477
680
|
const oxyInstance = this;
|
|
478
681
|
return async (socket, next) => {
|
|
479
682
|
try {
|
|
@@ -485,7 +688,13 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
485
688
|
try {
|
|
486
689
|
decoded = (0, jwt_decode_1.jwtDecode)(token);
|
|
487
690
|
}
|
|
488
|
-
catch {
|
|
691
|
+
catch (decodeError) {
|
|
692
|
+
if (debug) {
|
|
693
|
+
loggerUtils_1.logger.debug('[oxy.authSocket] Token decode failed', {
|
|
694
|
+
component: 'auth',
|
|
695
|
+
method: 'authSocket',
|
|
696
|
+
}, decodeError);
|
|
697
|
+
}
|
|
489
698
|
return next(new Error('Invalid token'));
|
|
490
699
|
}
|
|
491
700
|
const userId = decoded.userId || decoded.id;
|
|
@@ -506,7 +715,13 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
506
715
|
return next(new Error('Session invalid'));
|
|
507
716
|
}
|
|
508
717
|
}
|
|
509
|
-
catch {
|
|
718
|
+
catch (validateErr) {
|
|
719
|
+
if (debug) {
|
|
720
|
+
loggerUtils_1.logger.debug('[oxy.authSocket] Session validation failed', {
|
|
721
|
+
component: 'auth',
|
|
722
|
+
method: 'authSocket',
|
|
723
|
+
}, validateErr);
|
|
724
|
+
}
|
|
510
725
|
return next(new Error('Session validation failed'));
|
|
511
726
|
}
|
|
512
727
|
}
|
|
@@ -520,13 +735,19 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
520
735
|
socket.data.token = token;
|
|
521
736
|
socket.user = { id: userId, userId, sessionId: decoded.sessionId };
|
|
522
737
|
if (debug) {
|
|
523
|
-
|
|
738
|
+
loggerUtils_1.logger.debug(`[oxy.authSocket] OK user=${userId}`, {
|
|
739
|
+
component: 'auth',
|
|
740
|
+
method: 'authSocket',
|
|
741
|
+
});
|
|
524
742
|
}
|
|
525
743
|
next();
|
|
526
744
|
}
|
|
527
745
|
catch (err) {
|
|
528
746
|
if (debug) {
|
|
529
|
-
|
|
747
|
+
loggerUtils_1.logger.debug('[oxy.authSocket] Error', {
|
|
748
|
+
component: 'auth',
|
|
749
|
+
method: 'authSocket',
|
|
750
|
+
}, err);
|
|
530
751
|
}
|
|
531
752
|
next(new Error('Authentication error'));
|
|
532
753
|
}
|
|
@@ -540,7 +761,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
540
761
|
* @example
|
|
541
762
|
* ```typescript
|
|
542
763
|
* // Protect internal endpoints
|
|
543
|
-
* app.use('/internal', oxy.serviceAuth());
|
|
764
|
+
* app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
|
|
544
765
|
*
|
|
545
766
|
* app.post('/internal/trigger', (req, res) => {
|
|
546
767
|
* console.log('Service app:', req.serviceApp);
|
|
@@ -563,5 +784,114 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
563
784
|
});
|
|
564
785
|
};
|
|
565
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Express.js middleware that enforces a specific service-token scope.
|
|
789
|
+
*
|
|
790
|
+
* Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
|
|
791
|
+
* (when delegation is in effect) `req.serviceActingAs.scopes`. The scope
|
|
792
|
+
* is granted if EITHER list contains it, mirroring the OAuth2 model where
|
|
793
|
+
* the app's app-level scopes and the per-user delegated scopes both count.
|
|
794
|
+
*
|
|
795
|
+
* Requests authenticated as a regular user (no service token) are rejected
|
|
796
|
+
* with 403 — scope-protected endpoints are service-to-service by design.
|
|
797
|
+
*
|
|
798
|
+
* @example
|
|
799
|
+
* ```typescript
|
|
800
|
+
* app.use(
|
|
801
|
+
* '/internal/files',
|
|
802
|
+
* oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
|
|
803
|
+
* oxy.requireScope('files:write'),
|
|
804
|
+
* );
|
|
805
|
+
* ```
|
|
806
|
+
*/
|
|
807
|
+
requireScope(scope) {
|
|
808
|
+
if (typeof scope !== 'string' || scope.length === 0) {
|
|
809
|
+
throw new Error('requireScope: scope must be a non-empty string');
|
|
810
|
+
}
|
|
811
|
+
return (req, res, next) => {
|
|
812
|
+
const appScopes = req.serviceApp?.scopes ?? [];
|
|
813
|
+
const delegatedScopes = req.serviceActingAs?.scopes ?? [];
|
|
814
|
+
if (!req.serviceApp) {
|
|
815
|
+
res.status(403).json({
|
|
816
|
+
error: 'SERVICE_TOKEN_REQUIRED',
|
|
817
|
+
message: 'Scope-protected endpoint requires a service token',
|
|
818
|
+
code: 'SERVICE_TOKEN_REQUIRED',
|
|
819
|
+
status: 403,
|
|
820
|
+
});
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (appScopes.includes(scope) || delegatedScopes.includes(scope)) {
|
|
824
|
+
next();
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
loggerUtils_1.logger.warn('[oxy.auth] Service token missing required scope', {
|
|
828
|
+
component: 'auth',
|
|
829
|
+
method: 'requireScope',
|
|
830
|
+
appId: req.serviceApp.appId,
|
|
831
|
+
required: scope,
|
|
832
|
+
});
|
|
833
|
+
res.status(403).json({
|
|
834
|
+
error: 'INSUFFICIENT_SCOPE',
|
|
835
|
+
message: `Required scope '${scope}' not granted`,
|
|
836
|
+
code: 'INSUFFICIENT_SCOPE',
|
|
837
|
+
status: 403,
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
}
|
|
566
841
|
};
|
|
567
842
|
}
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
// Service token verification helpers
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
/**
|
|
847
|
+
* Verify a service JWT's HMAC-SHA256 signature using a constant-time compare.
|
|
848
|
+
* Throws `ServiceTokenStructureError` on malformed tokens and
|
|
849
|
+
* `ServiceTokenSignatureError` on signature mismatch — both map to 401.
|
|
850
|
+
*/
|
|
851
|
+
async function verifyServiceTokenSignature(token, secret) {
|
|
852
|
+
const nodeCrypto = await (0, platformCrypto_1.loadNodeCrypto)();
|
|
853
|
+
const { createHmac, timingSafeEqual } = nodeCrypto;
|
|
854
|
+
const parts = token.split('.');
|
|
855
|
+
if (parts.length !== 3) {
|
|
856
|
+
throw new ServiceTokenStructureError(`Service token must have 3 parts, got ${parts.length}`);
|
|
857
|
+
}
|
|
858
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
859
|
+
if (!headerB64 || !payloadB64 || !signatureB64) {
|
|
860
|
+
throw new ServiceTokenStructureError('Service token has empty segment');
|
|
861
|
+
}
|
|
862
|
+
const expectedSig = createHmac('sha256', secret)
|
|
863
|
+
.update(`${headerB64}.${payloadB64}`)
|
|
864
|
+
.digest('base64')
|
|
865
|
+
.replace(/\+/g, '-')
|
|
866
|
+
.replace(/\//g, '_')
|
|
867
|
+
.replace(/=/g, '');
|
|
868
|
+
const sigBuf = Buffer.from(signatureB64);
|
|
869
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
870
|
+
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
871
|
+
throw new ServiceTokenSignatureError();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Verify that a decoded service-token payload carries the expected `aud`,
|
|
876
|
+
* `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
|
|
877
|
+
* This is the defence against the H4 vulnerability where a recovery / 2FA /
|
|
878
|
+
* access token signed by the same shared secret could be replayed as a
|
|
879
|
+
* service token because no claim binding existed.
|
|
880
|
+
*/
|
|
881
|
+
function verifyServiceTokenClaims(decoded, expected) {
|
|
882
|
+
if (decoded.type !== 'service') {
|
|
883
|
+
throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
|
|
884
|
+
}
|
|
885
|
+
if (decoded.iss !== expected.issuer) {
|
|
886
|
+
throw new ServiceTokenClaimError(`Service token issuer mismatch: expected '${expected.issuer}', got '${String(decoded.iss)}'`);
|
|
887
|
+
}
|
|
888
|
+
const aud = decoded.aud;
|
|
889
|
+
if (Array.isArray(aud)) {
|
|
890
|
+
if (!aud.includes(expected.audience)) {
|
|
891
|
+
throw new ServiceTokenClaimError(`Service token audience does not include '${expected.audience}'`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
else if (aud !== expected.audience) {
|
|
895
|
+
throw new ServiceTokenClaimError(`Service token audience mismatch: expected '${expected.audience}', got '${String(aud)}'`);
|
|
896
|
+
}
|
|
897
|
+
}
|