@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.
@@ -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
- // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
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 verification = await oxyInstance.verifyActingAs(req.userId, actingAsUserId);
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: req.userId, ...req.user };
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
- console.log(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${req.originalUser.id}`);
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
- const authHeader = req.headers['authorization'];
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
- console.log(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`);
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 (not just decode).
226
- // This middleware only runs on a Node Express server, but the file
227
- // is bundled by Metro/Vite for RN/web consumers. `loadNodeCrypto`
228
- // is per-platform: the RN variant throws (and is never called
229
- // because service-token middleware is only mounted by Node hosts),
230
- // so Metro never bundles a reference to Node's built-in.
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
- const nodeCrypto = await (0, platformCrypto_1.loadNodeCrypto)();
233
- const { createHmac, timingSafeEqual } = nodeCrypto;
234
- const [headerB64, payloadB64, signatureB64] = token.split('.');
235
- if (!headerB64 || !payloadB64 || !signatureB64) {
236
- throw new Error('Invalid token structure');
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
- const isSignatureError = verifyError instanceof Error &&
253
- (verifyError.message === 'Invalid signature' || verifyError.message === 'Invalid token structure');
254
- if (!isSignatureError) {
255
- console.error('[oxy.auth] Unexpected error during service token verification:', verifyError);
256
- const error = { error: 'AUTH_INTERNAL_ERROR', message: 'Internal authentication error', code: 'AUTH_INTERNAL_ERROR', status: 500 };
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(500).json(error);
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
- const error = { error: 'INVALID_SERVICE_TOKEN', message: 'Invalid service token signature', code: 'INVALID_SERVICE_TOKEN', status: 401 };
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(401).json(error);
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
- if (!decoded.appId) {
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 oxyUserId = req.headers['x-oxy-user-id'];
297
- req.userId = oxyUserId || null;
298
- req.user = oxyUserId ? { id: oxyUserId } : null;
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: decoded.appId || '',
475
+ appId,
302
476
  appName: decoded.appName || 'unknown',
477
+ scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
303
478
  };
304
479
  if (debug) {
305
- console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
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
- console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
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
- console.log(`[oxy.auth] Session validation failed:`, validationError);
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
- // Failed to load user, continue with basic user object
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
- console.log(`[oxy.auth] OK user=${userId} (no session)`);
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 apiError = oxyInstance.handleError(error);
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
- console.log(`[oxy.auth] Error:`, apiError);
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((apiError && apiError.status) || 500).json(apiError);
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
- // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
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
- console.log(`[oxy.authSocket] OK user=${userId}`);
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
- console.log(`[oxy.authSocket] Error:`, err);
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
+ }