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