@oxyhq/core 1.11.12 → 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.
Files changed (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +3 -1
  3. package/dist/cjs/HttpService.js +214 -33
  4. package/dist/cjs/OxyServices.base.js +9 -0
  5. package/dist/cjs/OxyServices.js +8 -3
  6. package/dist/cjs/crypto/index.js +3 -1
  7. package/dist/cjs/crypto/keyManager.js +476 -172
  8. package/dist/cjs/crypto/polyfill.js +14 -65
  9. package/dist/cjs/crypto/recoveryPhrase.js +30 -11
  10. package/dist/cjs/crypto/signatureService.js +25 -60
  11. package/dist/cjs/i18n/locales/en-US.json +46 -1
  12. package/dist/cjs/i18n/locales/es-ES.json +46 -1
  13. package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
  14. package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
  15. package/dist/cjs/index.js +10 -2
  16. package/dist/cjs/mixins/OxyServices.assets.js +9 -4
  17. package/dist/cjs/mixins/OxyServices.auth.js +147 -14
  18. package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
  19. package/dist/cjs/mixins/OxyServices.features.js +0 -11
  20. package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
  21. package/dist/cjs/mixins/OxyServices.language.js +5 -36
  22. package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
  23. package/dist/cjs/mixins/OxyServices.security.js +13 -2
  24. package/dist/cjs/mixins/OxyServices.user.js +59 -38
  25. package/dist/cjs/mixins/OxyServices.utility.js +416 -110
  26. package/dist/cjs/mixins/index.js +11 -3
  27. package/dist/cjs/utils/accountUtils.js +71 -2
  28. package/dist/cjs/utils/deviceManager.js +5 -36
  29. package/dist/cjs/utils/languageUtils.js +22 -0
  30. package/dist/cjs/utils/platformCrypto.js +165 -0
  31. package/dist/cjs/utils/platformCrypto.native.js +123 -0
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/CrossDomainAuth.js +3 -1
  34. package/dist/esm/HttpService.js +215 -34
  35. package/dist/esm/OxyServices.base.js +9 -0
  36. package/dist/esm/OxyServices.js +8 -3
  37. package/dist/esm/crypto/index.js +1 -1
  38. package/dist/esm/crypto/keyManager.js +473 -138
  39. package/dist/esm/crypto/polyfill.js +14 -32
  40. package/dist/esm/crypto/recoveryPhrase.js +30 -11
  41. package/dist/esm/crypto/signatureService.js +25 -27
  42. package/dist/esm/i18n/locales/en-US.json +46 -1
  43. package/dist/esm/i18n/locales/es-ES.json +46 -1
  44. package/dist/esm/i18n/locales/locales/en-US.json +46 -1
  45. package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
  46. package/dist/esm/index.js +4 -3
  47. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  48. package/dist/esm/mixins/OxyServices.auth.js +145 -14
  49. package/dist/esm/mixins/OxyServices.contacts.js +47 -0
  50. package/dist/esm/mixins/OxyServices.features.js +0 -11
  51. package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
  52. package/dist/esm/mixins/OxyServices.language.js +5 -3
  53. package/dist/esm/mixins/OxyServices.redirect.js +6 -2
  54. package/dist/esm/mixins/OxyServices.security.js +13 -2
  55. package/dist/esm/mixins/OxyServices.user.js +59 -38
  56. package/dist/esm/mixins/OxyServices.utility.js +416 -77
  57. package/dist/esm/mixins/index.js +11 -3
  58. package/dist/esm/utils/accountUtils.js +67 -1
  59. package/dist/esm/utils/deviceManager.js +5 -3
  60. package/dist/esm/utils/languageUtils.js +21 -0
  61. package/dist/esm/utils/platformCrypto.js +125 -0
  62. package/dist/esm/utils/platformCrypto.native.js +80 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/HttpService.d.ts +47 -3
  65. package/dist/types/OxyServices.base.d.ts +7 -0
  66. package/dist/types/OxyServices.d.ts +50 -7
  67. package/dist/types/crypto/index.d.ts +1 -1
  68. package/dist/types/crypto/keyManager.d.ts +110 -9
  69. package/dist/types/crypto/polyfill.d.ts +3 -1
  70. package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
  71. package/dist/types/crypto/signatureService.d.ts +4 -0
  72. package/dist/types/index.d.ts +7 -5
  73. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  74. package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
  75. package/dist/types/mixins/OxyServices.auth.d.ts +82 -5
  76. package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
  77. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  78. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.features.d.ts +2 -7
  80. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.user.d.ts +28 -11
  92. package/dist/types/mixins/OxyServices.utility.d.ts +145 -10
  93. package/dist/types/mixins/index.d.ts +52 -4
  94. package/dist/types/models/interfaces.d.ts +62 -3
  95. package/dist/types/utils/accountUtils.d.ts +41 -1
  96. package/dist/types/utils/languageUtils.d.ts +1 -0
  97. package/dist/types/utils/platformCrypto.d.ts +87 -0
  98. package/dist/types/utils/platformCrypto.native.d.ts +54 -0
  99. package/package.json +45 -2
  100. package/src/CrossDomainAuth.ts +12 -10
  101. package/src/HttpService.ts +251 -40
  102. package/src/OxyServices.base.ts +10 -0
  103. package/src/OxyServices.ts +26 -7
  104. package/src/crypto/__tests__/keyManager.test.ts +336 -0
  105. package/src/crypto/index.ts +6 -1
  106. package/src/crypto/keyManager.ts +529 -151
  107. package/src/crypto/polyfill.ts +14 -34
  108. package/src/crypto/recoveryPhrase.ts +56 -17
  109. package/src/crypto/signatureService.ts +25 -30
  110. package/src/i18n/locales/en-US.json +46 -1
  111. package/src/i18n/locales/es-ES.json +46 -1
  112. package/src/index.ts +19 -4
  113. package/src/mixins/OxyServices.assets.ts +15 -11
  114. package/src/mixins/OxyServices.auth.ts +175 -15
  115. package/src/mixins/OxyServices.contacts.ts +73 -0
  116. package/src/mixins/OxyServices.features.ts +2 -12
  117. package/src/mixins/OxyServices.fedcm.ts +4 -3
  118. package/src/mixins/OxyServices.language.ts +6 -4
  119. package/src/mixins/OxyServices.redirect.ts +6 -2
  120. package/src/mixins/OxyServices.security.ts +18 -8
  121. package/src/mixins/OxyServices.user.ts +72 -49
  122. package/src/mixins/OxyServices.utility.ts +562 -89
  123. package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
  124. package/src/mixins/index.ts +58 -7
  125. package/src/models/interfaces.ts +65 -3
  126. package/src/utils/accountUtils.ts +82 -2
  127. package/src/utils/deviceManager.ts +7 -4
  128. package/src/utils/languageUtils.ts +23 -2
  129. package/src/utils/platformCrypto.native.ts +101 -0
  130. package/src/utils/platformCrypto.ts +145 -0
@@ -5,13 +5,58 @@
5
5
  * and Express.js authentication middleware
6
6
  */
7
7
  import { jwtDecode } from 'jwt-decode';
8
+ import { loadNodeCrypto } from '../utils/platformCrypto.js';
9
+ import { logger } from '../utils/loggerUtils.js';
8
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
+ }
9
42
  export function OxyServicesUtilityMixin(Base) {
10
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.
11
48
  constructor(...args) {
12
49
  super(...args);
13
50
  /** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
14
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();
15
60
  }
16
61
  /**
17
62
  * Verify that a user is authorized to act as a managed account.
@@ -37,7 +82,13 @@ export function OxyServicesUtilityMixin(Base) {
37
82
  });
38
83
  return result && result.authorized ? result : null;
39
84
  }
40
- 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);
41
92
  // Cache negative result for 1 minute to avoid hammering on transient errors
42
93
  this._actingAsCache.set(cacheKey, {
43
94
  result: null,
@@ -46,6 +97,53 @@ export function OxyServicesUtilityMixin(Base) {
46
97
  return null;
47
98
  }
48
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
+ }
49
147
  /**
50
148
  * Fetch link metadata
51
149
  */
@@ -73,9 +171,17 @@ export function OxyServicesUtilityMixin(Base) {
73
171
  * - Security comes from API-based session validation (`validateSession()`)
74
172
  * which checks the session server-side on every request
75
173
  * - Service tokens (type: 'service') DO use cryptographic HMAC verification
76
- * 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.
77
177
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
78
- * 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.
79
185
  *
80
186
  * @example
81
187
  * ```typescript
@@ -84,7 +190,7 @@ export function OxyServicesUtilityMixin(Base) {
84
190
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
85
191
  *
86
192
  * // Protect all routes under /protected
87
- * app.use('/protected', oxy.auth());
193
+ * app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
88
194
  *
89
195
  * // Access user in route handler
90
196
  * app.get('/protected/me', (req, res) => {
@@ -96,14 +202,18 @@ export function OxyServicesUtilityMixin(Base) {
96
202
  *
97
203
  * // Optional auth - attach user if present, don't block if absent
98
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'));
99
208
  * ```
100
209
  *
101
210
  * @param options Optional configuration
102
211
  * @returns Express middleware function
103
212
  */
104
213
  auth(options = {}) {
105
- const { debug = false, onError, loadUser = false, optional = false, jwtSecret } = options;
106
- // 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.
107
217
  const oxyInstance = this;
108
218
  // Return an async middleware function
109
219
  return async (req, res, next) => {
@@ -113,9 +223,12 @@ export function OxyServicesUtilityMixin(Base) {
113
223
  // the managed account, preserving the original user for audit trails.
114
224
  const processActingAs = async () => {
115
225
  const actingAsUserId = req.headers['x-acting-as'];
116
- if (!actingAsUserId)
226
+ if (!actingAsUserId || typeof actingAsUserId !== 'string')
117
227
  return true; // No header, proceed normally
118
- 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);
119
232
  if (!verification) {
120
233
  const error = {
121
234
  error: 'ACTING_AS_UNAUTHORIZED',
@@ -132,23 +245,25 @@ export function OxyServicesUtilityMixin(Base) {
132
245
  return false;
133
246
  }
134
247
  // Preserve original user for audit trails
135
- req.originalUser = { id: req.userId, ...req.user };
248
+ req.originalUser = { id: currentUserId, ...(req.user ?? {}) };
136
249
  req.actingAs = { userId: actingAsUserId, role: verification.role };
137
250
  // Swap user identity to the managed account
138
251
  req.userId = actingAsUserId;
139
- req.user = { id: actingAsUserId };
140
- // Also set _id for routes that use Pattern B (req.user._id)
141
- if (req.user) {
142
- req.user._id = actingAsUserId;
143
- }
252
+ req.user = { id: actingAsUserId, _id: actingAsUserId };
144
253
  if (debug) {
145
- 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
+ });
146
258
  }
147
259
  return true;
148
260
  };
149
261
  try {
150
- // Extract token from Authorization header or query params
151
- 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;
152
267
  let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
153
268
  // Fallback to query params (useful for WebSocket upgrades)
154
269
  if (!token) {
@@ -159,7 +274,10 @@ export function OxyServicesUtilityMixin(Base) {
159
274
  token = q.access_token;
160
275
  }
161
276
  if (debug) {
162
- 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
+ });
163
281
  }
164
282
  if (!token) {
165
283
  if (optional) {
@@ -183,6 +301,12 @@ export function OxyServicesUtilityMixin(Base) {
183
301
  decoded = jwtDecode(token);
184
302
  }
185
303
  catch (decodeError) {
304
+ if (debug) {
305
+ logger.debug('[oxy.auth] Token decode failed', {
306
+ component: 'auth',
307
+ method: 'auth',
308
+ }, decodeError);
309
+ }
186
310
  if (optional) {
187
311
  req.userId = null;
188
312
  req.user = null;
@@ -218,49 +342,72 @@ export function OxyServicesUtilityMixin(Base) {
218
342
  return onError(error);
219
343
  return res.status(403).json(error);
220
344
  }
221
- // Verify JWT signature (not just decode)
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.
222
353
  try {
223
- const { createHmac } = await import('crypto');
224
- const [headerB64, payloadB64, signatureB64] = token.split('.');
225
- if (!headerB64 || !payloadB64 || !signatureB64) {
226
- throw new Error('Invalid token structure');
227
- }
228
- const expectedSig = createHmac('sha256', jwtSecret)
229
- .update(`${headerB64}.${payloadB64}`)
230
- .digest('base64')
231
- .replace(/\+/g, '-')
232
- .replace(/\//g, '_')
233
- .replace(/=/g, '');
234
- // Timing-safe comparison
235
- const sigBuf = Buffer.from(signatureB64);
236
- const expectedBuf = Buffer.from(expectedSig);
237
- const { timingSafeEqual } = await import('crypto');
238
- if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
239
- throw new Error('Invalid signature');
240
- }
354
+ await verifyServiceTokenSignature(token, jwtSecret);
355
+ verifyServiceTokenClaims(decoded, {
356
+ audience: expectedAudience,
357
+ issuer: expectedIssuer,
358
+ });
241
359
  }
242
360
  catch (verifyError) {
243
- const isSignatureError = verifyError instanceof Error &&
244
- (verifyError.message === 'Invalid signature' || verifyError.message === 'Invalid token structure');
245
- if (!isSignatureError) {
246
- console.error('[oxy.auth] Unexpected error during service token verification:', verifyError);
247
- 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
+ };
248
391
  if (onError)
249
392
  return onError(error);
250
- return res.status(500).json(error);
251
- }
252
- if (optional) {
253
- req.userId = null;
254
- req.user = null;
255
- return next();
393
+ return res.status(401).json(error);
256
394
  }
257
- 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
+ };
258
405
  if (onError)
259
406
  return onError(error);
260
- return res.status(401).json(error);
407
+ return res.status(500).json(error);
261
408
  }
262
- // Check expiration
263
- if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
409
+ // Check expiration — reject tokens at exact expiry second (use <=)
410
+ if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
264
411
  if (optional) {
265
412
  req.userId = null;
266
413
  req.user = null;
@@ -272,7 +419,8 @@ export function OxyServicesUtilityMixin(Base) {
272
419
  return res.status(401).json(error);
273
420
  }
274
421
  // Validate required service token fields
275
- if (!decoded.appId) {
422
+ const appId = decoded.appId;
423
+ if (!appId) {
276
424
  if (optional) {
277
425
  req.userId = null;
278
426
  req.user = null;
@@ -284,16 +432,52 @@ export function OxyServicesUtilityMixin(Base) {
284
432
  return res.status(401).json(error);
285
433
  }
286
434
  // Read delegated user ID from header
287
- const oxyUserId = req.headers['x-oxy-user-id'];
288
- req.userId = oxyUserId || null;
289
- 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
+ }
290
470
  req.accessToken = token;
291
471
  req.serviceApp = {
292
- appId: decoded.appId || '',
472
+ appId,
293
473
  appName: decoded.appName || 'unknown',
474
+ scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
294
475
  };
295
476
  if (debug) {
296
- 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
+ });
297
481
  }
298
482
  return next();
299
483
  }
@@ -315,7 +499,8 @@ export function OxyServicesUtilityMixin(Base) {
315
499
  return res.status(401).json(error);
316
500
  }
317
501
  // Check token expiration locally first (fast path)
318
- if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
502
+ // Reject tokens at exact expiry second (use <=)
503
+ if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
319
504
  if (optional) {
320
505
  req.userId = null;
321
506
  req.user = null;
@@ -366,7 +551,10 @@ export function OxyServicesUtilityMixin(Base) {
366
551
  req.user = { id: userId };
367
552
  }
368
553
  if (debug) {
369
- 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
+ });
370
558
  }
371
559
  // Process X-Acting-As header before proceeding
372
560
  if (await processActingAs())
@@ -375,7 +563,10 @@ export function OxyServicesUtilityMixin(Base) {
375
563
  }
376
564
  catch (validationError) {
377
565
  if (debug) {
378
- console.log(`[oxy.auth] Session validation failed:`, validationError);
566
+ logger.debug('[oxy.auth] Session validation failed', {
567
+ component: 'auth',
568
+ method: 'auth',
569
+ }, validationError);
379
570
  }
380
571
  if (optional) {
381
572
  req.userId = null;
@@ -415,25 +606,44 @@ export function OxyServicesUtilityMixin(Base) {
415
606
  req.user = fullUser;
416
607
  }
417
608
  }
418
- catch {
419
- // 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);
420
618
  }
421
619
  }
422
620
  if (debug) {
423
- 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
+ });
424
625
  }
425
626
  // Process X-Acting-As header before proceeding
426
627
  if (await processActingAs())
427
628
  next();
428
629
  }
429
630
  catch (error) {
430
- 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
+ };
431
638
  if (debug) {
432
- console.log(`[oxy.auth] Error:`, apiError);
639
+ logger.debug('[oxy.auth] Error', {
640
+ component: 'auth',
641
+ method: 'auth',
642
+ }, apiError);
433
643
  }
434
644
  if (onError)
435
645
  return onError(apiError);
436
- return res.status((apiError && apiError.status) || 500).json(apiError);
646
+ return res.status(apiError.status).json(apiError);
437
647
  }
438
648
  };
439
649
  }
@@ -463,7 +673,7 @@ export function OxyServicesUtilityMixin(Base) {
463
673
  */
464
674
  authSocket(options = {}) {
465
675
  const { debug = false } = options;
466
- // 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.
467
677
  const oxyInstance = this;
468
678
  return async (socket, next) => {
469
679
  try {
@@ -475,15 +685,21 @@ export function OxyServicesUtilityMixin(Base) {
475
685
  try {
476
686
  decoded = jwtDecode(token);
477
687
  }
478
- catch {
688
+ catch (decodeError) {
689
+ if (debug) {
690
+ logger.debug('[oxy.authSocket] Token decode failed', {
691
+ component: 'auth',
692
+ method: 'authSocket',
693
+ }, decodeError);
694
+ }
479
695
  return next(new Error('Invalid token'));
480
696
  }
481
697
  const userId = decoded.userId || decoded.id;
482
698
  if (!userId) {
483
699
  return next(new Error('Invalid token payload'));
484
700
  }
485
- // Check expiration
486
- if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
701
+ // Check expiration — reject tokens at exact expiry second (use <=)
702
+ if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
487
703
  return next(new Error('Token expired'));
488
704
  }
489
705
  // Validate session if available
@@ -496,25 +712,39 @@ export function OxyServicesUtilityMixin(Base) {
496
712
  return next(new Error('Session invalid'));
497
713
  }
498
714
  }
499
- catch {
715
+ catch (validateErr) {
716
+ if (debug) {
717
+ logger.debug('[oxy.authSocket] Session validation failed', {
718
+ component: 'auth',
719
+ method: 'authSocket',
720
+ }, validateErr);
721
+ }
500
722
  return next(new Error('Session validation failed'));
501
723
  }
502
724
  }
503
- // Attach user data to socket
725
+ // Attach user data to socket. We expose BOTH `socket.data.userId`
726
+ // (the official Socket.IO data slot) and `socket.user` because
727
+ // every consumer in this ecosystem (Mention, Allo, api/server.ts)
728
+ // reads from `socket.user.id`.
504
729
  socket.data = socket.data || {};
505
730
  socket.data.userId = userId;
506
731
  socket.data.sessionId = decoded.sessionId || null;
507
732
  socket.data.token = token;
508
- // Also set on socket.user for backward compatibility
509
733
  socket.user = { id: userId, userId, sessionId: decoded.sessionId };
510
734
  if (debug) {
511
- console.log(`[oxy.authSocket] OK user=${userId}`);
735
+ logger.debug(`[oxy.authSocket] OK user=${userId}`, {
736
+ component: 'auth',
737
+ method: 'authSocket',
738
+ });
512
739
  }
513
740
  next();
514
741
  }
515
742
  catch (err) {
516
743
  if (debug) {
517
- console.log(`[oxy.authSocket] Error:`, err);
744
+ logger.debug('[oxy.authSocket] Error', {
745
+ component: 'auth',
746
+ method: 'authSocket',
747
+ }, err);
518
748
  }
519
749
  next(new Error('Authentication error'));
520
750
  }
@@ -528,7 +758,7 @@ export function OxyServicesUtilityMixin(Base) {
528
758
  * @example
529
759
  * ```typescript
530
760
  * // Protect internal endpoints
531
- * app.use('/internal', oxy.serviceAuth());
761
+ * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
532
762
  *
533
763
  * app.post('/internal/trigger', (req, res) => {
534
764
  * console.log('Service app:', req.serviceApp);
@@ -551,5 +781,114 @@ export function OxyServicesUtilityMixin(Base) {
551
781
  });
552
782
  };
553
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
+ }
554
838
  };
555
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
+ }