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