@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.
@@ -8,6 +8,7 @@ import { jwtDecode } from 'jwt-decode';
8
8
  import type { ApiError, User } from '../models/interfaces';
9
9
  import type { OxyServicesBase } from '../OxyServices.base';
10
10
  import { loadNodeCrypto } from '../utils/platformCrypto';
11
+ import { logger } from '../utils/loggerUtils';
11
12
  import { CACHE_TIMES } from './mixinHelpers';
12
13
 
13
14
  interface JwtPayload {
@@ -18,7 +19,10 @@ interface JwtPayload {
18
19
  type?: string;
19
20
  appId?: string;
20
21
  appName?: string;
21
- [key: string]: any;
22
+ scopes?: string[];
23
+ aud?: string | string[];
24
+ iss?: string;
25
+ [key: string]: unknown;
22
26
  }
23
27
 
24
28
  /**
@@ -31,11 +35,67 @@ interface ActingAsVerification {
31
35
  }
32
36
 
33
37
  /**
34
- * Service app metadata attached to requests authenticated with service tokens
38
+ * Result from the service-acting-as verification endpoint.
39
+ * Confirms that a given service app holds an active delegation grant for
40
+ * the supplied user, along with the explicit scope list the grant covers.
41
+ *
42
+ * The api side persists these via the `ServiceActingAs` model:
43
+ * { serviceAppId, userId, scopes: string[], grantedAt, expiresAt }
44
+ *
45
+ * The SDK never inspects the grant directly — it round-trips through
46
+ * `GET /internal/service-acting-as/verify?appId=...&userId=...` so the
47
+ * authoritative store stays server-side.
48
+ */
49
+ export interface ServiceActingAsVerification {
50
+ authorized: boolean;
51
+ scopes: string[];
52
+ }
53
+
54
+ /**
55
+ * Service app metadata attached to requests authenticated with service tokens.
56
+ * `scopes` reflects the scopes granted to the app at signup time (from the
57
+ * `DeveloperApp.scopes` field); route-level checks can require additional
58
+ * scope-narrowing via `requireScope()`.
35
59
  */
36
60
  export interface ServiceApp {
37
61
  appId: string;
38
62
  appName: string;
63
+ scopes: string[];
64
+ }
65
+
66
+ /**
67
+ * Expected JWT audience for tokens issued by the Oxy auth service.
68
+ */
69
+ const OXY_JWT_AUDIENCE = 'oxy-api';
70
+ /**
71
+ * Expected JWT issuer for tokens issued by the Oxy auth service.
72
+ */
73
+ const OXY_JWT_ISSUER = 'oxy-auth';
74
+
75
+ /**
76
+ * Sentinel error classes for service-token verification. Using classes (not
77
+ * message strings) makes the catch site below safe to extend: a new failure
78
+ * mode added later cannot silently fall through to the generic 500 branch.
79
+ */
80
+ class ServiceTokenStructureError extends Error {
81
+ constructor(message = 'Service token has malformed structure') {
82
+ super(message);
83
+ this.name = 'ServiceTokenStructureError';
84
+ }
85
+ }
86
+
87
+ class ServiceTokenSignatureError extends Error {
88
+ constructor(message = 'Service token signature is invalid') {
89
+ super(message);
90
+ this.name = 'ServiceTokenSignatureError';
91
+ }
92
+ }
93
+
94
+ class ServiceTokenClaimError extends Error {
95
+ constructor(message: string) {
96
+ super(message);
97
+ this.name = 'ServiceTokenClaimError';
98
+ }
39
99
  }
40
100
 
41
101
  /**
@@ -45,7 +105,7 @@ interface AuthMiddlewareOptions {
45
105
  /** Enable debug logging (default: false) */
46
106
  debug?: boolean;
47
107
  /** Custom error handler - receives error object, can return response */
48
- onError?: (error: ApiError) => any;
108
+ onError?: (error: ApiError) => unknown;
49
109
  /** Load full user profile from API (default: false for performance) */
50
110
  loadUser?: boolean;
51
111
  /** Optional auth - attach user if token present but don't block (default: false) */
@@ -54,8 +114,24 @@ interface AuthMiddlewareOptions {
54
114
  * JWT secret for verifying service token signatures locally.
55
115
  * When provided, service tokens will be cryptographically verified.
56
116
  * When omitted, service tokens will be rejected (secure default).
117
+ *
118
+ * **Migration note (>=1.11.14):** the Oxy API now signs service tokens
119
+ * with a dedicated `SERVICE_TOKEN_SECRET` distinct from `ACCESS_TOKEN_SECRET`.
120
+ * Pass that value here. If you keep passing the access-token secret you will
121
+ * still verify ALL signed-by-Oxy tokens (which is the whole class of bug
122
+ * H4 was supposed to prevent — DO NOT do that in production).
57
123
  */
58
124
  jwtSecret?: string;
125
+ /**
126
+ * Expected JWT issuer. Defaults to `'oxy-auth'`. Override only if you run
127
+ * a private fork of the Oxy auth server under a different `iss` claim.
128
+ */
129
+ expectedIssuer?: string;
130
+ /**
131
+ * Expected JWT audience. Defaults to `'oxy-api'`. Override only if your
132
+ * private fork mints tokens for a different audience.
133
+ */
134
+ expectedAudience?: string;
59
135
  }
60
136
 
61
137
  export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T) {
@@ -63,6 +139,19 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
63
139
  /** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
64
140
  _actingAsCache = new Map<string, { result: ActingAsVerification | null; expiresAt: number }>();
65
141
 
142
+ /**
143
+ * In-memory cache for service-acting-as verification.
144
+ * Negative results are cached for 1min to avoid hammering the verify
145
+ * endpoint when a service is misconfigured; positive grants are cached
146
+ * for 5min to amortize the round-trip without holding stale grants too long.
147
+ * @internal
148
+ */
149
+ _serviceActingAsCache = new Map<string, { result: ServiceActingAsVerification | null; expiresAt: number }>();
150
+
151
+ // TypeScript's mixin pattern requires `(...args: any[])` here — the
152
+ // constructor signature is a structural shape check the compiler enforces.
153
+ // Matches every other mixin in this package; do not change without a
154
+ // monorepo-wide refactor of the mixin pipeline.
66
155
  constructor(...args: any[]) {
67
156
  super(...(args as [any]));
68
157
  }
@@ -99,7 +188,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
99
188
  });
100
189
 
101
190
  return result && result.authorized ? result : null;
102
- } catch {
191
+ } catch (error) {
192
+ logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
193
+ component: 'auth',
194
+ method: 'verifyActingAs',
195
+ userId,
196
+ accountId,
197
+ }, error);
103
198
  // Cache negative result for 1 minute to avoid hammering on transient errors
104
199
  this._actingAsCache.set(cacheKey, {
105
200
  result: null,
@@ -109,6 +204,66 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
109
204
  }
110
205
  }
111
206
 
207
+ /**
208
+ * Verify that a service app holds an active delegation grant authorising
209
+ * it to act on behalf of `userId`. Returns the grant (with allowed scopes)
210
+ * on success or `null` if no valid grant exists. Negative answers are
211
+ * cached briefly to protect the verify endpoint from misconfigured callers.
212
+ *
213
+ * Implemented as a per-instance Map keyed by `appId:userId`. Cached
214
+ * positive grants live for 5 minutes (acceptable staleness window for an
215
+ * impersonation grant); revocations propagate within that window.
216
+ *
217
+ * @internal Used by the auth() middleware — not part of the public API
218
+ */
219
+ async verifyServiceActingAs(
220
+ appId: string,
221
+ userId: string,
222
+ ): Promise<ServiceActingAsVerification | null> {
223
+ const cacheKey = `${appId}:${userId}`;
224
+ const now = Date.now();
225
+
226
+ const cached = this._serviceActingAsCache.get(cacheKey);
227
+ if (cached && cached.expiresAt > now) {
228
+ return cached.result;
229
+ }
230
+
231
+ try {
232
+ const result = await this.makeRequest<ServiceActingAsVerification>(
233
+ 'GET',
234
+ '/internal/service-acting-as/verify',
235
+ { appId, userId },
236
+ { cache: false, retry: false, timeout: 5000 },
237
+ );
238
+
239
+ const authorized = Boolean(result && result.authorized);
240
+ const verified: ServiceActingAsVerification | null = authorized
241
+ ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
242
+ : null;
243
+
244
+ this._serviceActingAsCache.set(cacheKey, {
245
+ result: verified,
246
+ expiresAt: now + 5 * 60 * 1000,
247
+ });
248
+
249
+ return verified;
250
+ } catch (error) {
251
+ logger.warn('[oxy.auth] verifyServiceActingAs lookup failed — caching negative result', {
252
+ component: 'auth',
253
+ method: 'verifyServiceActingAs',
254
+ appId,
255
+ userId,
256
+ }, error);
257
+ // Negative cache prevents a runaway loop if the verify endpoint is
258
+ // down, while still letting a real grant become visible within 60s.
259
+ this._serviceActingAsCache.set(cacheKey, {
260
+ result: null,
261
+ expiresAt: now + 1 * 60 * 1000,
262
+ });
263
+ return null;
264
+ }
265
+ }
266
+
112
267
  /**
113
268
  * Fetch link metadata
114
269
  */
@@ -146,9 +301,17 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
146
301
  * - Security comes from API-based session validation (`validateSession()`)
147
302
  * which checks the session server-side on every request
148
303
  * - Service tokens (type: 'service') DO use cryptographic HMAC verification
149
- * via the `jwtSecret` option, since they are stateless
304
+ * via the `jwtSecret` option, since they are stateless. Service tokens
305
+ * are additionally checked for `aud`, `iss`, and `type` claims to prevent
306
+ * cross-token-type confusion attacks.
150
307
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
151
- * direct access to `ACCESS_TOKEN_SECRET`
308
+ * direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
309
+ *
310
+ * **Service-token delegation (X-Oxy-User-Id):**
311
+ * When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
312
+ * `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
313
+ * grant exists before attaching `req.userId`. A missing/expired grant
314
+ * results in a 403 — there is no fail-open path.
152
315
  *
153
316
  * @example
154
317
  * ```typescript
@@ -157,7 +320,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
157
320
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
158
321
  *
159
322
  * // Protect all routes under /protected
160
- * app.use('/protected', oxy.auth());
323
+ * app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
161
324
  *
162
325
  * // Access user in route handler
163
326
  * app.get('/protected/me', (req, res) => {
@@ -169,27 +332,41 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
169
332
  *
170
333
  * // Optional auth - attach user if present, don't block if absent
171
334
  * app.use('/public', oxy.auth({ optional: true }));
335
+ *
336
+ * // Require a specific scope on a service-token-protected route
337
+ * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
172
338
  * ```
173
339
  *
174
340
  * @param options Optional configuration
175
341
  * @returns Express middleware function
176
342
  */
177
343
  auth(options: AuthMiddlewareOptions = {}) {
178
- const { debug = false, onError, loadUser = false, optional = false, jwtSecret } = options;
179
- // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
180
- const oxyInstance = this as any;
344
+ const {
345
+ debug = false,
346
+ onError,
347
+ loadUser = false,
348
+ optional = false,
349
+ jwtSecret,
350
+ expectedIssuer = OXY_JWT_ISSUER,
351
+ expectedAudience = OXY_JWT_AUDIENCE,
352
+ } = options;
353
+ // Cross-mixin method access: typed as a structural subset of the
354
+ // composed OxyServices we know we have at runtime.
355
+ const oxyInstance = this as unknown as OxyAuthInstance;
181
356
 
182
357
  // Return an async middleware function
183
- return async (req: any, res: any, next: any) => {
358
+ return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
184
359
  // Process X-Acting-As header for managed account identity delegation.
185
360
  // Called after successful authentication, before next(). If the header
186
361
  // is present, verifies authorization and swaps the request identity to
187
362
  // the managed account, preserving the original user for audit trails.
188
363
  const processActingAs = async (): Promise<boolean> => {
189
- const actingAsUserId = req.headers['x-acting-as'] as string | undefined;
190
- if (!actingAsUserId) return true; // No header, proceed normally
364
+ const actingAsUserId = req.headers['x-acting-as'];
365
+ if (!actingAsUserId || typeof actingAsUserId !== 'string') return true; // No header, proceed normally
366
+ const currentUserId = req.userId;
367
+ if (!currentUserId) return true; // No authenticated user yet — nothing to swap
191
368
 
192
- const verification = await oxyInstance.verifyActingAs(req.userId, actingAsUserId);
369
+ const verification = await oxyInstance.verifyActingAs(currentUserId, actingAsUserId);
193
370
  if (!verification) {
194
371
  const error = {
195
372
  error: 'ACTING_AS_UNAUTHORIZED',
@@ -206,27 +383,29 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
206
383
  }
207
384
 
208
385
  // Preserve original user for audit trails
209
- req.originalUser = { id: req.userId, ...req.user };
386
+ req.originalUser = { id: currentUserId, ...(req.user ?? {}) };
210
387
  req.actingAs = { userId: actingAsUserId, role: verification.role };
211
388
 
212
389
  // Swap user identity to the managed account
213
390
  req.userId = actingAsUserId;
214
- req.user = { id: actingAsUserId } as any;
215
- // Also set _id for routes that use Pattern B (req.user._id)
216
- if (req.user) {
217
- (req.user as any)._id = actingAsUserId;
218
- }
391
+ req.user = { id: actingAsUserId, _id: actingAsUserId } as unknown as User;
219
392
 
220
393
  if (debug) {
221
- console.log(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${req.originalUser.id}`);
394
+ logger.debug(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${currentUserId}`, {
395
+ component: 'auth',
396
+ method: 'auth.processActingAs',
397
+ });
222
398
  }
223
399
 
224
400
  return true;
225
401
  };
226
402
 
227
403
  try {
228
- // Extract token from Authorization header or query params
229
- const authHeader = req.headers['authorization'];
404
+ // Extract token from Authorization header or query params.
405
+ // Node/Express normalizes `Authorization` to a string; we guard
406
+ // against the (legal but unusual) string[] case anyway.
407
+ const rawAuthHeader = req.headers.authorization;
408
+ const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader;
230
409
  let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
231
410
 
232
411
  // Fallback to query params (useful for WebSocket upgrades)
@@ -237,7 +416,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
237
416
  }
238
417
 
239
418
  if (debug) {
240
- console.log(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`);
419
+ logger.debug(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`, {
420
+ component: 'auth',
421
+ method: 'auth',
422
+ });
241
423
  }
242
424
 
243
425
  if (!token) {
@@ -262,6 +444,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
262
444
  try {
263
445
  decoded = jwtDecode<JwtPayload>(token);
264
446
  } catch (decodeError) {
447
+ if (debug) {
448
+ logger.debug('[oxy.auth] Token decode failed', {
449
+ component: 'auth',
450
+ method: 'auth',
451
+ }, decodeError);
452
+ }
265
453
  if (optional) {
266
454
  req.userId = null;
267
455
  req.user = null;
@@ -298,51 +486,69 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
298
486
  return res.status(403).json(error);
299
487
  }
300
488
 
301
- // Verify JWT signature (not just decode).
302
- // This middleware only runs on a Node Express server, but the file
303
- // is bundled by Metro/Vite for RN/web consumers. `loadNodeCrypto`
304
- // is per-platform: the RN variant throws (and is never called
305
- // because service-token middleware is only mounted by Node hosts),
306
- // so Metro never bundles a reference to Node's built-in.
489
+ // Verify JWT signature, then audience / issuer / type / appId claims.
490
+ //
491
+ // Signature verification uses a manual HMAC-SHA256 compare because
492
+ // this file ships into RN/web bundles where `jsonwebtoken` is
493
+ // unavailable. The middleware only ever runs on Node hosts (see
494
+ // platformCrypto's doc-comment), and `loadNodeCrypto` is per-
495
+ // platform: the RN variant throws so Metro never bundles a Node
496
+ // built-in reference.
307
497
  try {
308
- const nodeCrypto = await loadNodeCrypto();
309
- const { createHmac, timingSafeEqual } = nodeCrypto;
310
- const [headerB64, payloadB64, signatureB64] = token.split('.');
311
- if (!headerB64 || !payloadB64 || !signatureB64) {
312
- throw new Error('Invalid token structure');
313
- }
314
- const expectedSig = createHmac('sha256', jwtSecret)
315
- .update(`${headerB64}.${payloadB64}`)
316
- .digest('base64')
317
- .replace(/\+/g, '-')
318
- .replace(/\//g, '_')
319
- .replace(/=/g, '');
320
-
321
- // Timing-safe comparison
322
- const sigBuf = Buffer.from(signatureB64);
323
- const expectedBuf = Buffer.from(expectedSig);
324
- if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
325
- throw new Error('Invalid signature');
326
- }
498
+ await verifyServiceTokenSignature(token, jwtSecret);
499
+ verifyServiceTokenClaims(decoded, {
500
+ audience: expectedAudience,
501
+ issuer: expectedIssuer,
502
+ });
327
503
  } catch (verifyError) {
328
- const isSignatureError = verifyError instanceof Error &&
329
- (verifyError.message === 'Invalid signature' || verifyError.message === 'Invalid token structure');
330
-
331
- if (!isSignatureError) {
332
- console.error('[oxy.auth] Unexpected error during service token verification:', verifyError);
333
- const error = { error: 'AUTH_INTERNAL_ERROR', message: 'Internal authentication error', code: 'AUTH_INTERNAL_ERROR', status: 500 };
504
+ // Structure + signature + claim errors all map to 401. Anything
505
+ // else (e.g. Node crypto failing to load on a misconfigured host)
506
+ // genuinely IS a 500.
507
+ if (
508
+ verifyError instanceof ServiceTokenStructureError ||
509
+ verifyError instanceof ServiceTokenSignatureError ||
510
+ verifyError instanceof ServiceTokenClaimError
511
+ ) {
512
+ if (debug) {
513
+ logger.debug('[oxy.auth] Service token rejected', {
514
+ component: 'auth',
515
+ method: 'auth.serviceToken',
516
+ reason: verifyError.name,
517
+ detail: verifyError.message,
518
+ });
519
+ }
520
+ if (optional) {
521
+ req.userId = null;
522
+ req.user = null;
523
+ return next();
524
+ }
525
+ const code = verifyError instanceof ServiceTokenSignatureError
526
+ ? 'INVALID_SERVICE_TOKEN'
527
+ : verifyError instanceof ServiceTokenStructureError
528
+ ? 'INVALID_SERVICE_TOKEN'
529
+ : 'INVALID_SERVICE_TOKEN_CLAIMS';
530
+ const error = {
531
+ error: code,
532
+ message: verifyError.message,
533
+ code,
534
+ status: 401,
535
+ };
334
536
  if (onError) return onError(error);
335
- return res.status(500).json(error);
537
+ return res.status(401).json(error);
336
538
  }
337
539
 
338
- if (optional) {
339
- req.userId = null;
340
- req.user = null;
341
- return next();
342
- }
343
- const error = { error: 'INVALID_SERVICE_TOKEN', message: 'Invalid service token signature', code: 'INVALID_SERVICE_TOKEN', status: 401 };
540
+ logger.error('[oxy.auth] Unexpected error during service token verification', verifyError, {
541
+ component: 'auth',
542
+ method: 'auth.serviceToken',
543
+ });
544
+ const error = {
545
+ error: 'AUTH_INTERNAL_ERROR',
546
+ message: 'Internal authentication error',
547
+ code: 'AUTH_INTERNAL_ERROR',
548
+ status: 500,
549
+ };
344
550
  if (onError) return onError(error);
345
- return res.status(401).json(error);
551
+ return res.status(500).json(error);
346
552
  }
347
553
 
348
554
  // Check expiration — reject tokens at exact expiry second (use <=)
@@ -358,7 +564,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
358
564
  }
359
565
 
360
566
  // Validate required service token fields
361
- if (!decoded.appId) {
567
+ const appId = decoded.appId;
568
+ if (!appId) {
362
569
  if (optional) {
363
570
  req.userId = null;
364
571
  req.user = null;
@@ -370,18 +577,54 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
370
577
  }
371
578
 
372
579
  // Read delegated user ID from header
373
- const oxyUserId = req.headers['x-oxy-user-id'] as string;
580
+ const oxyUserIdRaw = req.headers['x-oxy-user-id'];
581
+ const oxyUserId = typeof oxyUserIdRaw === 'string' && oxyUserIdRaw.length > 0 ? oxyUserIdRaw : null;
582
+
583
+ // C3: a service may only act as a user when an explicit
584
+ // ServiceActingAs grant exists for that (appId, userId) pair.
585
+ // Without the grant we MUST refuse — silently attaching
586
+ // `req.userId = oxyUserId` would let any service impersonate
587
+ // any user simply by setting the header.
588
+ if (oxyUserId) {
589
+ const grant = await oxyInstance.verifyServiceActingAs(appId, oxyUserId);
590
+ if (!grant || !grant.authorized) {
591
+ logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
592
+ component: 'auth',
593
+ method: 'auth.serviceToken',
594
+ appId,
595
+ attemptedUserId: oxyUserId,
596
+ });
597
+ const error = {
598
+ error: 'SERVICE_ACTING_AS_UNAUTHORIZED',
599
+ message: 'Service not authorized to act as this user',
600
+ code: 'SERVICE_ACTING_AS_UNAUTHORIZED',
601
+ status: 403,
602
+ };
603
+ if (onError) return onError(error);
604
+ return res.status(403).json(error);
605
+ }
606
+
607
+ req.userId = oxyUserId;
608
+ req.user = { id: oxyUserId } as User;
609
+ req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
610
+ } else {
611
+ // No X-Oxy-User-Id means the service is acting as itself.
612
+ req.userId = null;
613
+ req.user = null;
614
+ }
374
615
 
375
- req.userId = oxyUserId || null;
376
- req.user = oxyUserId ? ({ id: oxyUserId } as User) : null;
377
616
  req.accessToken = token;
378
617
  req.serviceApp = {
379
- appId: decoded.appId || '',
618
+ appId,
380
619
  appName: decoded.appName || 'unknown',
620
+ scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
381
621
  };
382
622
 
383
623
  if (debug) {
384
- console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
624
+ logger.debug(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`, {
625
+ component: 'auth',
626
+ method: 'auth.serviceToken',
627
+ });
385
628
  }
386
629
 
387
630
  return next();
@@ -462,7 +705,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
462
705
  }
463
706
 
464
707
  if (debug) {
465
- console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
708
+ logger.debug(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`, {
709
+ component: 'auth',
710
+ method: 'auth',
711
+ });
466
712
  }
467
713
 
468
714
  // Process X-Acting-As header before proceeding
@@ -470,7 +716,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
470
716
  return;
471
717
  } catch (validationError) {
472
718
  if (debug) {
473
- console.log(`[oxy.auth] Session validation failed:`, validationError);
719
+ logger.debug('[oxy.auth] Session validation failed', {
720
+ component: 'auth',
721
+ method: 'auth',
722
+ }, validationError);
474
723
  }
475
724
 
476
725
  if (optional) {
@@ -512,26 +761,49 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
512
761
  if (fullUser) {
513
762
  req.user = fullUser;
514
763
  }
515
- } catch {
516
- // Failed to load user, continue with basic user object
764
+ } catch (loadUserError) {
765
+ // Loading the full user is best-effort here; the basic { id }
766
+ // object is already attached. Log so misconfigured deployments
767
+ // can be diagnosed instead of silently failing.
768
+ logger.warn('[oxy.auth] loadUser fallback — could not fetch full profile', {
769
+ component: 'auth',
770
+ method: 'auth.loadUser',
771
+ userId,
772
+ }, loadUserError);
517
773
  }
518
774
  }
519
775
 
520
776
  if (debug) {
521
- console.log(`[oxy.auth] OK user=${userId} (no session)`);
777
+ logger.debug(`[oxy.auth] OK user=${userId} (no session)`, {
778
+ component: 'auth',
779
+ method: 'auth',
780
+ });
522
781
  }
523
782
 
524
783
  // Process X-Acting-As header before proceeding
525
784
  if (await processActingAs()) next();
526
785
  } catch (error) {
527
- const apiError = oxyInstance.handleError(error) as any;
786
+ const handled = oxyInstance.handleError(error) as Error & {
787
+ code?: string;
788
+ status?: number;
789
+ details?: Record<string, unknown>;
790
+ };
791
+ const apiError: ApiError = {
792
+ message: handled.message || 'Authentication error',
793
+ code: handled.code ?? 'AUTH_ERROR',
794
+ status: handled.status ?? 500,
795
+ details: handled.details,
796
+ };
528
797
 
529
798
  if (debug) {
530
- console.log(`[oxy.auth] Error:`, apiError);
799
+ logger.debug('[oxy.auth] Error', {
800
+ component: 'auth',
801
+ method: 'auth',
802
+ }, apiError);
531
803
  }
532
804
 
533
805
  if (onError) return onError(apiError);
534
- return res.status((apiError && apiError.status) || 500).json(apiError);
806
+ return res.status(apiError.status).json(apiError);
535
807
  }
536
808
  };
537
809
  }
@@ -562,10 +834,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
562
834
  */
563
835
  authSocket(options: { debug?: boolean } = {}) {
564
836
  const { debug = false } = options;
565
- // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
566
- const oxyInstance = this as any;
837
+ // Cross-mixin method access typed via the same structural subset.
838
+ const oxyInstance = this as unknown as OxyAuthInstance;
567
839
 
568
- return async (socket: any, next: (err?: Error) => void) => {
840
+ return async (socket: SocketLike, next: (err?: Error) => void) => {
569
841
  try {
570
842
  const token = socket.handshake?.auth?.token;
571
843
 
@@ -576,7 +848,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
576
848
  let decoded: JwtPayload;
577
849
  try {
578
850
  decoded = jwtDecode<JwtPayload>(token);
579
- } catch {
851
+ } catch (decodeError) {
852
+ if (debug) {
853
+ logger.debug('[oxy.authSocket] Token decode failed', {
854
+ component: 'auth',
855
+ method: 'authSocket',
856
+ }, decodeError);
857
+ }
580
858
  return next(new Error('Invalid token'));
581
859
  }
582
860
 
@@ -599,7 +877,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
599
877
  if (!result || !result.valid) {
600
878
  return next(new Error('Session invalid'));
601
879
  }
602
- } catch {
880
+ } catch (validateErr) {
881
+ if (debug) {
882
+ logger.debug('[oxy.authSocket] Session validation failed', {
883
+ component: 'auth',
884
+ method: 'authSocket',
885
+ }, validateErr);
886
+ }
603
887
  return next(new Error('Session validation failed'));
604
888
  }
605
889
  }
@@ -616,13 +900,19 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
616
900
  socket.user = { id: userId, userId, sessionId: decoded.sessionId };
617
901
 
618
902
  if (debug) {
619
- console.log(`[oxy.authSocket] OK user=${userId}`);
903
+ logger.debug(`[oxy.authSocket] OK user=${userId}`, {
904
+ component: 'auth',
905
+ method: 'authSocket',
906
+ });
620
907
  }
621
908
 
622
909
  next();
623
910
  } catch (err) {
624
911
  if (debug) {
625
- console.log(`[oxy.authSocket] Error:`, err);
912
+ logger.debug('[oxy.authSocket] Error', {
913
+ component: 'auth',
914
+ method: 'authSocket',
915
+ }, err);
626
916
  }
627
917
  next(new Error('Authentication error'));
628
918
  }
@@ -636,7 +926,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
636
926
  * @example
637
927
  * ```typescript
638
928
  * // Protect internal endpoints
639
- * app.use('/internal', oxy.serviceAuth());
929
+ * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
640
930
  *
641
931
  * app.post('/internal/trigger', (req, res) => {
642
932
  * console.log('Service app:', req.serviceApp);
@@ -644,10 +934,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
644
934
  * });
645
935
  * ```
646
936
  */
647
- serviceAuth(options: { debug?: boolean; jwtSecret?: string } = {}) {
937
+ serviceAuth(options: { debug?: boolean; jwtSecret?: string; expectedIssuer?: string; expectedAudience?: string } = {}) {
648
938
  const innerAuth = this.auth({ ...options });
649
939
 
650
- return async (req: any, res: any, next: any) => {
940
+ return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
651
941
  await innerAuth(req, res, () => {
652
942
  if (!req.serviceApp) {
653
943
  return res.status(403).json({
@@ -660,6 +950,180 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
660
950
  });
661
951
  };
662
952
  }
953
+
954
+ /**
955
+ * Express.js middleware that enforces a specific service-token scope.
956
+ *
957
+ * Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
958
+ * (when delegation is in effect) `req.serviceActingAs.scopes`. The scope
959
+ * is granted if EITHER list contains it, mirroring the OAuth2 model where
960
+ * the app's app-level scopes and the per-user delegated scopes both count.
961
+ *
962
+ * Requests authenticated as a regular user (no service token) are rejected
963
+ * with 403 — scope-protected endpoints are service-to-service by design.
964
+ *
965
+ * @example
966
+ * ```typescript
967
+ * app.use(
968
+ * '/internal/files',
969
+ * oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
970
+ * oxy.requireScope('files:write'),
971
+ * );
972
+ * ```
973
+ */
974
+ requireScope(scope: string) {
975
+ if (typeof scope !== 'string' || scope.length === 0) {
976
+ throw new Error('requireScope: scope must be a non-empty string');
977
+ }
978
+
979
+ return (req: AuthReq, res: AuthRes, next: AuthNext): void => {
980
+ const appScopes = req.serviceApp?.scopes ?? [];
981
+ const delegatedScopes = req.serviceActingAs?.scopes ?? [];
982
+
983
+ if (!req.serviceApp) {
984
+ res.status(403).json({
985
+ error: 'SERVICE_TOKEN_REQUIRED',
986
+ message: 'Scope-protected endpoint requires a service token',
987
+ code: 'SERVICE_TOKEN_REQUIRED',
988
+ status: 403,
989
+ });
990
+ return;
991
+ }
992
+
993
+ if (appScopes.includes(scope) || delegatedScopes.includes(scope)) {
994
+ next();
995
+ return;
996
+ }
997
+
998
+ logger.warn('[oxy.auth] Service token missing required scope', {
999
+ component: 'auth',
1000
+ method: 'requireScope',
1001
+ appId: req.serviceApp.appId,
1002
+ required: scope,
1003
+ });
1004
+ res.status(403).json({
1005
+ error: 'INSUFFICIENT_SCOPE',
1006
+ message: `Required scope '${scope}' not granted`,
1007
+ code: 'INSUFFICIENT_SCOPE',
1008
+ status: 403,
1009
+ });
1010
+ };
1011
+ }
663
1012
  };
664
1013
  }
665
1014
 
1015
+ // ---------------------------------------------------------------------------
1016
+ // Service token verification helpers
1017
+ // ---------------------------------------------------------------------------
1018
+
1019
+ /**
1020
+ * Verify a service JWT's HMAC-SHA256 signature using a constant-time compare.
1021
+ * Throws `ServiceTokenStructureError` on malformed tokens and
1022
+ * `ServiceTokenSignatureError` on signature mismatch — both map to 401.
1023
+ */
1024
+ async function verifyServiceTokenSignature(token: string, secret: string): Promise<void> {
1025
+ const nodeCrypto = await loadNodeCrypto();
1026
+ const { createHmac, timingSafeEqual } = nodeCrypto;
1027
+ const parts = token.split('.');
1028
+ if (parts.length !== 3) {
1029
+ throw new ServiceTokenStructureError(`Service token must have 3 parts, got ${parts.length}`);
1030
+ }
1031
+ const [headerB64, payloadB64, signatureB64] = parts;
1032
+ if (!headerB64 || !payloadB64 || !signatureB64) {
1033
+ throw new ServiceTokenStructureError('Service token has empty segment');
1034
+ }
1035
+ const expectedSig = createHmac('sha256', secret)
1036
+ .update(`${headerB64}.${payloadB64}`)
1037
+ .digest('base64')
1038
+ .replace(/\+/g, '-')
1039
+ .replace(/\//g, '_')
1040
+ .replace(/=/g, '');
1041
+
1042
+ const sigBuf = Buffer.from(signatureB64);
1043
+ const expectedBuf = Buffer.from(expectedSig);
1044
+ if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
1045
+ throw new ServiceTokenSignatureError();
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Verify that a decoded service-token payload carries the expected `aud`,
1051
+ * `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
1052
+ * This is the defence against the H4 vulnerability where a recovery / 2FA /
1053
+ * access token signed by the same shared secret could be replayed as a
1054
+ * service token because no claim binding existed.
1055
+ */
1056
+ function verifyServiceTokenClaims(
1057
+ decoded: JwtPayload,
1058
+ expected: { audience: string; issuer: string },
1059
+ ): void {
1060
+ if (decoded.type !== 'service') {
1061
+ throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
1062
+ }
1063
+ if (decoded.iss !== expected.issuer) {
1064
+ throw new ServiceTokenClaimError(`Service token issuer mismatch: expected '${expected.issuer}', got '${String(decoded.iss)}'`);
1065
+ }
1066
+ const aud = decoded.aud;
1067
+ if (Array.isArray(aud)) {
1068
+ if (!aud.includes(expected.audience)) {
1069
+ throw new ServiceTokenClaimError(`Service token audience does not include '${expected.audience}'`);
1070
+ }
1071
+ } else if (aud !== expected.audience) {
1072
+ throw new ServiceTokenClaimError(`Service token audience mismatch: expected '${expected.audience}', got '${String(aud)}'`);
1073
+ }
1074
+ }
1075
+
1076
+ // ---------------------------------------------------------------------------
1077
+ // Local request/response/socket typing
1078
+ //
1079
+ // Express's types are an optional peer (we don't want to take a hard dep on
1080
+ // `@types/express` from a platform-agnostic SDK). The structural subset below
1081
+ // captures everything this middleware actually touches, so consumers get type
1082
+ // checking without us coupling to Express's full surface.
1083
+ // ---------------------------------------------------------------------------
1084
+
1085
+ interface AuthReq {
1086
+ method?: string;
1087
+ path?: string;
1088
+ headers: Record<string, string | string[] | undefined>;
1089
+ query?: Record<string, unknown>;
1090
+ userId?: string | null;
1091
+ user?: User | null;
1092
+ accessToken?: string;
1093
+ sessionId?: string | null;
1094
+ serviceApp?: ServiceApp;
1095
+ serviceActingAs?: { userId: string; scopes: string[] };
1096
+ actingAs?: { userId: string; role: string };
1097
+ originalUser?: { id: string } & Partial<User>;
1098
+ }
1099
+
1100
+ interface AuthRes {
1101
+ status(code: number): AuthRes;
1102
+ json(body: unknown): unknown;
1103
+ }
1104
+
1105
+ type AuthNext = (err?: unknown) => void;
1106
+
1107
+ interface SocketLike {
1108
+ handshake?: { auth?: { token?: string } };
1109
+ data?: Record<string, unknown>;
1110
+ user?: { id: string; userId: string; sessionId?: string | null };
1111
+ }
1112
+
1113
+ interface OxyAuthInstance {
1114
+ verifyActingAs(userId: string, accountId: string): Promise<ActingAsVerification | null>;
1115
+ verifyServiceActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null>;
1116
+ validateSession(
1117
+ sessionId: string,
1118
+ options?: { deviceFingerprint?: string; useHeaderValidation?: boolean },
1119
+ ): Promise<{
1120
+ valid: boolean;
1121
+ user?: User;
1122
+ [key: string]: unknown;
1123
+ } | null>;
1124
+ getAccessToken(): string | null;
1125
+ setTokens(accessToken: string, refreshToken?: string): void;
1126
+ clearTokens(): void;
1127
+ getCurrentUser(): Promise<User | null>;
1128
+ handleError(error: unknown): Error;
1129
+ }